Passwords are a liability. Users forget them, reuse them, and your support queue fills up with reset requests. Magic links give users a frictionless login experience -- they enter their email, click a link, and they're in. No password to remember, no reset flow to build.
Here is how to add magic link authentication to a Claude Code Boilerplate Next.js App Router SaaS using Resend to send the links and Drizzle ORM to track and expire tokens.
The flow has four steps:
The whole thing is stateless from the user's perspective. No passwords, no sessions to manage on the client side.
You need one new table: magic_links. Add it alongside your existing users table.
// modules/auth/magic-link.schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { userTable } from '../user/user.schema'
export const magicLinkTable = pgTable('magic_links', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => userTable.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
})
Register it in db/drizzle.ts, then run npm run db:generate && npm run db:migrate.
Two operations: sendMagicLink and verifyMagicLink.
// modules/auth/magic-link.service.ts
import crypto from 'crypto'
import React from 'react'
import { db } from '@/db/drizzle'
import { magicLinkTable } from './magic-link.schema'
import { userTable } from '../user/user.schema'
import { emailService } from '@/lib/email'
import { signJwt } from '@/lib/auth'
import { HttpError } from '@/lib/errors'
import { eq, and, gt, isNull } from 'drizzle-orm'
import { MagicLinkEmail } from '@/emails/MagicLinkEmail'
export async function sendMagicLink(email: string) {
const [user] = await db
.select()
.from(userTable)
.where(eq(userTable.email, email))
.limit(1)
// Return silently -- never reveal whether the email is registered
if (!user) return
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)
await db.insert(magicLinkTable).values({ userId: user.id, token, expiresAt })
const url = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/magic-link/verify?token=${token}`
await emailService.sendEmail({
to: email,
subject: 'Your login link',
react: React.createElement(MagicLinkEmail, { url, appName: 'Your App' }),
})
}
export async function verifyMagicLink(token: string): Promise<string> {
const [link] = await db
.select()
.from(magicLinkTable)
.where(
and(
eq(magicLinkTable.token, token),
isNull(magicLinkTable.usedAt),
gt(magicLinkTable.expiresAt, new Date())
)
)
.limit(1)
if (!link) throw new HttpError(400, 'Invalid or expired link')
// Mark used before issuing the JWT so a race cannot replay the token
await db
.update(magicLinkTable)
.set({ usedAt: new Date() })
.where(eq(magicLinkTable.id, link.id))
return signJwt({ userId: link.userId })
}
The verify function checks three conditions in one query: the token must exist, must not be used, and must not be expired. If any condition fails, the user gets a single generic error -- do not tell them which condition failed.
Two thin routes: one to request a link, one to handle the callback.
// app/api/auth/magic-link/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { sendMagicLink } from '@/modules/auth/magic-link.service'
import { handleError } from '@/lib/errors'
import { z } from 'zod/v4'
const schema = z.object({ email: z.email() })
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { data, success } = schema.safeParse(body)
if (!success) return NextResponse.json({ error: 'Invalid email' }, { status: 400 })
await sendMagicLink(data.email)
// Always 200 -- do not reveal whether the email is registered
return NextResponse.json({ message: 'If that email is registered, a link is on its way.' })
} catch (error: unknown) {
return handleError(error)
}
}
// app/api/auth/magic-link/verify/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyMagicLink } from '@/modules/auth/magic-link.service'
export async function GET(req: NextRequest) {
const base = process.env.NEXT_PUBLIC_BASE_URL!
const token = req.nextUrl.searchParams.get('token')
if (!token) return NextResponse.redirect(`${base}/login?error=missing_token`)
try {
const jwt = await verifyMagicLink(token)
const response = NextResponse.redirect(`${base}/dashboard`)
response.cookies.set('token', jwt, { httpOnly: true, sameSite: 'lax', secure: true })
return response
} catch {
return NextResponse.redirect(`${base}/login?error=invalid_link`)
}
}
// components/magic-link-form.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
export function MagicLinkForm() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
res.ok ? setSent(true) : toast.error('Something went wrong. Please try again.')
}
if (sent) return <p>Check your inbox -- your login link is on the way.</p>
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<Input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
<Button type="submit">Send login link</Button>
</form>
)
}
A few things worth getting right from the start:
usedAt is set before the JWT is issued, blocking replaysYou do not need to hash magic link tokens the way you would for long-lived API keys. The 15-minute expiry and single-use constraint already keep the blast radius small.
If you have JWT signing, a user table, and an email service already wired up, magic link authentication takes about an hour to add from scratch. You need the schema, the two service functions, two route files, the login form, and an email template.
With Claude Code Boilerplate, Resend, JWT auth, and the email service are already configured and connected. Clone the repo, add the schema and service above, and your users are logging in without a password before lunch. No password reset flow to build, no bcrypt to maintain -- just a link in an inbox.