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.
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)→base64urltoken- 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/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin.env.local, noNEXT_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 link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| UX | One click | Type digits |
| Storage | SHA-256 of 32-byte token | HMAC of email:code + pepper |
| Typical TTL | ~30 minutes | ~10 minutes |
| Good for | Desktop/mobile browser sign-in | Shared 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
- Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
- Click tracking off on that subdomain (or a dedicated security domain).
ROVELA_API_KEYwithsending_accessonly on the server (Vercel/env secrets, neverNEXT_PUBLIC_).fromuses the verified subdomain; html + text both present; full URL in text.Idempotency-Keyon every logical send; redeem is delete-and-return.- Enumeration-safe request response; rate-limit the request route.
- Treat send
200as 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.