Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Magic link authentication in Next.js App Router -- passwordless login with Resend and Drizzle ORM

June 29, 2026
nextjsauthresenddrizzle-ormsaas

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.

What you are building

The flow has four steps:

  1. User enters their email on the login page
  2. Your API creates a random token, stores it in the DB, and sends a login link via Resend
  3. User clicks the link -- your callback route verifies the token and issues a JWT
  4. Token is marked used and the user is redirected to the dashboard

The whole thing is stateless from the user's perspective. No passwords, no sessions to manage on the client side.

The schema

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.

The service

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.

The API routes

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`)
  }
}

The login form

// 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>
  )
}

Security basics

A few things worth getting right from the start:

  • Tokens expire in 15 minutes -- short enough to limit exposure, long enough for normal inboxes
  • Tokens are single-use -- usedAt is set before the JWT is issued, blocking replays
  • The request endpoint always returns 200 -- never confirm whether an email is registered
  • Tokens are 32 random bytes (256 bits) -- brute-force is not practical

You 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.

How long does this take?

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.