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

FilePurpose
components/features/magic-link/magic-link-form.tsxEmail input form with send + "Check your email" success states

Setup

1

Install dependencies

pnpm add resend
2

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

VariableRequiredDescription
RESEND_API_KEYYesResend API key
EMAIL_FROMYesVerified sender address
NEXT_PUBLIC_APP_URLYesBase URL for the magic link
NEXT_PUBLIC_APP_NAMENoApp name in email subject

Verification

  1. Enter a valid email and click "Send Magic Link"
  2. Check inbox — sign-in email arrives within seconds
  3. Click the link — redirects to /dashboard
  4. Click the link again — should show token already consumed error
  5. Wait 15+ minutes and retry an old link — shows expired error

Demo Mode — Explore freely. Some actions are restricted. demo@launchfst.dev / demo1234

Get LaunchFst →