← Back to blog

Magic Link Login in Next.js (2026) with a Plain HTTP Email API

Build passwordless sign-in in the App Router: create a single-use token, email it with POST /emails, redeem once—and keep click tracking off for the link.

Updated November 4, 2026
Magic Link Login in Next.js (2026) with a Plain HTTP Email API

Why Next.js + a plain HTTP email API

Next.js App Router already gives you the two pieces passwordless login needs: Route Handlers for “email me a link” and “redeem this token,” and server-only env for the API key. You do not need a Rovela SDK, NextAuth provider, or a special “magic login” product endpoint.

What you need is the same pattern as password reset links: your app creates a single-use token, stores a hash, puts the raw token only in the email URL, and POST /emails delivers the message. Rovela queues and DKIM-signs; it does not mint sessions.

This article is magic-link focused. For generic “how do I send mail from App Router,” see Sending Email from Next.js with the Rovela API. For the product-agnostic token recipe, see Magic Login Email Links.

What you own vs what Rovela sends

Your Next.js app owns:

  • crypto.randomBytes(32) → base64url token
  • SHA-256 hex of the token in your database (never store the raw token)
  • Expiry (the docs example uses ~30 minutes for reset-style links)
  • Absolute URL on your origin: https://app.example.com/login/magic?token=…
  • Atomic redeem (delete-and-return) + session cookie
  • Identical API responses whether or not the email is registered

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in .env.local, no NEXT_PUBLIC_ prefix)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Token store (32 bytes, hash only, short TTL)

// lib/magic-link.ts
import crypto from 'node:crypto'
import 'server-only'

export function createMagicToken() {
  const token = crypto.randomBytes(32).toString('base64url')
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000)
  return { token, tokenHash, expiresAt }
}

Upsert by user: replace any previous unused link so only the latest email works. Plain SHA-256 is enough for 32 random bytes (unlike six-digit OTPs, which need HMAC + pepper — see verification codes).

Send route: app/api/auth/magic/request/route.ts

import { NextResponse } from 'next/server'
import { createMagicToken } from '@/lib/magic-link'
// import { db } from '@/lib/db'  // your store

export async function POST(req: Request) {
  const { email: raw } = await req.json()
  const email = String(raw ?? '').trim().toLowerCase()

  const user = await db.users.findByEmail(email)
  if (user) {
    const { token, tokenHash, expiresAt } = createMagicToken()
    await db.magicLogins.upsert({ userId: user.id, tokenHash, expiresAt })

    const link = `https://app.acme.com/login/magic?token=${token}`
    const res = await fetch('https://api.rovela.dev/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': `magic-${tokenHash.slice(0, 32)}`,
      },
      body: JSON.stringify({
        from: 'Acme <security@mail.acme.com>',
        to: email,
        subject: 'Your Acme sign-in link',
        html:
          `<p>Use this link to sign in to Acme.</p>` +
          `<p><a href="${link}">Sign in</a></p>` +
          `<p>The link works once and expires in 30 minutes. ` +
          `If you did not request it, ignore this email.</p>`,
        text:
          `Use this link to sign in to Acme.\n\n` +
          `Sign in: ${link}\n\n` +
          `The link works once and expires in 30 minutes.`,
      }),
    })

    if (!res.ok) {
      // Log server-side; still return the same client message below.
      console.error('rovela send failed', res.status, await res.text())
    }
  }

  // Same copy + status whether or not the account exists.
  return NextResponse.json({
    message: 'If that address has an account, a sign-in link is on its way.',
  })
}

Always send both html and text, with the full URL in the text part. Use a verified subdomain for auth (security@mail.…). Derive Idempotency-Key from the token hash so a timeout retry does not enqueue a second message; a deliberate “resend” means a new token and a new key.

Redeem route: app/api/auth/magic/redeem/route.ts

import { NextResponse } from 'next/server'
import crypto from 'node:crypto'
// import { db } from '@/lib/db'
// import { createSessionCookie } from '@/lib/session'

export async function POST(req: Request) {
  const { token } = await req.json()
  if (!token || typeof token !== 'string') {
    return NextResponse.json({ error: 'invalid_link' }, { status: 400 })
  }

  const tokenHash = crypto.createHash('sha256').update(token).digest('hex')

  // Spend in one step so two parallel clicks cannot both succeed.
  const row = await db.query(
    `DELETE FROM magic_logins
     WHERE token_hash = $1 AND expires_at > NOW()
     RETURNING user_id`,
    [tokenHash]
  )

  if (row.rowCount === 0) {
    return NextResponse.json({ error: 'expired_or_used' }, { status: 400 })
  }

  const userId = row.rows[0].user_id
  const cookie = await createSessionCookie(userId)
  const res = NextResponse.json({ ok: true })
  res.headers.append('Set-Cookie', cookie)
  return res
}

Wire a page at /login/magic that reads ?token= and POSTs to this handler. On failure, show a generic “link expired or already used” UI and a button to request a new link—do not distinguish unknown vs spent vs expired if that helps an attacker.

Keep click tracking off for the login URL

Open/click tracking is a per-domain setting. When click tracking is on, Rovela rewrites links through a tracking host. The login still works after the redirect, but the token takes an extra hop.

For magic links (and password resets / invite tokens), prefer a separate sending domain or subdomain with click tracking off, so the token goes straight to your app. That is the same guidance as the password-reset example. If auth mail still lands in spam, use the checklist in Why OTP and Magic-Link Emails Go to Spam.

Magic link vs OTP in the same Next.js app

Magic linkOTP (six digits)
Docs shapeSame as password resetVerification codes
UXOne clickType digits
StorageSHA-256 of 32-byte tokenHMAC of email:code + pepper
Typical TTL~30 minutes~10 minutes
Good forDesktop/mobile browser sign-inShared screens, step-up “confirm it is you”

Both call the same POST /emails. Keep separate tables and routes—do not reuse an OTP row as a magic-link token.

Deploy checklist

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. Click tracking off on that subdomain (or a dedicated security domain).
  3. ROVELA_API_KEY with sending_access only on the server (Vercel/env secrets, never NEXT_PUBLIC_).
  4. from uses the verified subdomain; html + text both present; full URL in text.
  5. Idempotency-Key on every logical send; redeem is delete-and-return.
  6. Enumeration-safe request response; rate-limit the request route.
  7. Treat send 200 as queued; use webhooks for bounces/complaints if you need delivery truth.

Ship the token machinery in Route Handlers, send with one fetch, keep tracking off on the security domain—and you have passwordless Next.js sign-in without pretending the email API is an identity provider.