Magic Link Auth
Passwordless sign-in via one-click email links. Uses Resend and cryptographic tokens.
Add passwordless magic link authentication in ~10 minutes. Tokens expire in 15 minutes and are single-use.
Files
| File | Purpose |
|---|---|
components/features/magic-link/magic-link-form.tsx | Email input form with send + "Check your email" success states |
Setup
1
Install dependencies
pnpm add resend2
Add environment variables
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
EMAIL_FROM="noreply@yourdomain.com"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_APP_NAME="YourSaaS"3
Add the Prisma model
model MagicLinkToken {
id String @id @default(cuid())
email String @unique
token String @unique
expires DateTime
createdAt DateTime @default(now())
}Then run: pnpm prisma db push
4
Create the send route
Create app/api/auth/magic-link/send/route.ts:
import { NextResponse } from "next/server"
import { z } from "zod"
import { db } from "@/lib/db"
import { Resend } from "resend"
import crypto from "crypto"
const resend = new Resend(process.env.RESEND_API_KEY)
const schema = z.object({ email: z.string().email() })
export async function POST(request: Request) {
const { email } = schema.parse(await request.json())
const token = crypto.randomBytes(32).toString("hex")
const expires = new Date(Date.now() + 15 * 60 * 1000)
await db.magicLinkToken.upsert({
where: { email },
create: { email, token, expires },
update: { token, expires },
select: { id: true },
})
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: email,
subject: `Sign in to ${process.env.NEXT_PUBLIC_APP_NAME ?? "YourSaaS"}`,
html: `<a href="${appUrl}/api/auth/magic-link/verify?token=${token}">Click to sign in</a>`,
})
return NextResponse.json({ success: true })
}5
Create the verify route
Create app/api/auth/magic-link/verify/route.ts:
import { NextResponse } from "next/server"
import { db } from "@/lib/db"
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token")
if (!token) return NextResponse.redirect(new URL("/auth/error?error=missing_token", request.url))
const record = await db.magicLinkToken.findUnique({ where: { token }, select: { email: true, expires: true } })
if (!record || record.expires < new Date()) {
return NextResponse.redirect(new URL("/auth/error?error=invalid_token", request.url))
}
await db.magicLinkToken.delete({ where: { token } })
// Sign the user in with NextAuth signIn() or create session
return NextResponse.redirect(new URL("/dashboard", process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"))
}6
Use the form component
import { MagicLinkForm } from "@/components/features/magic-link/magic-link-form"
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<MagicLinkForm />
</div>
)
}Environment Variables
| Variable | Required | Description |
|---|---|---|
RESEND_API_KEY | Yes | Resend API key |
EMAIL_FROM | Yes | Verified sender address |
NEXT_PUBLIC_APP_URL | Yes | Base URL for the magic link |
NEXT_PUBLIC_APP_NAME | No | App name in email subject |
Verification
- Enter a valid email and click "Send Magic Link"
- Check inbox — sign-in email arrives within seconds
- Click the link — redirects to
/dashboard - Click the link again — should show token already consumed error
- Wait 15+ minutes and retry an old link — shows expired error