
How do I integrate Resend with Next.js (App Router) for password reset and magic link emails?
Building password reset and magic link flows with Next.js (App Router) and Resend is a clean way to modernize your authentication UX without relying on bulky third-party auth UIs. With the right setup, you can send email links securely, handle token verification on the server, and keep your API routes organized inside the app directory.
This guide walks through a practical, end-to-end setup so you can integrate Resend with Next.js (App Router) for password reset and magic link emails in a production-ready way.
Overview of the architecture
To integrate Resend with Next.js (App Router) for password reset and magic link flows, you’ll typically need:
- A Resend account and API key
- A Next.js project using the App Router (
appdirectory) - A database (e.g., PostgreSQL, MySQL, or Prisma ORM) to store users and tokens
- API routes under
app/apito:- Request a password reset or magic link
- Send the email via Resend
- Validate the token when the user clicks the email link
- A secure token generation strategy (short-lived, one-time usage)
At a high level:
- User submits email address.
- Your API route generates a token, stores it in the database, and sends an email via Resend.
- User clicks the link from the email (with the token as a query parameter).
- A Next.js route validates the token and either:
- Shows a password reset form, or
- Automatically logs the user in (magic link) and sets a session/cookie.
Step 1: Install and configure Resend in Next.js
First, install the Resend SDK:
npm install resend
# or
yarn add resend
# or
pnpm add resend
Create an environment variable in your .env.local:
RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM="Your App <no-reply@yourdomain.com>"
APP_URL=http://localhost:3000
APP_URLis used to build password reset / magic link URLs. In production, set this to your real domain.
Now create a small Resend utility, e.g. lib/resend.ts:
// lib/resend.ts
import { Resend } from 'resend';
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is not set');
}
export const resend = new Resend(process.env.RESEND_API_KEY);
export const EMAIL_FROM = process.env.EMAIL_FROM ?? 'no-reply@example.com';
export const APP_URL = process.env.APP_URL ?? 'http://localhost:3000';
Step 2: Design the database schema for tokens
You need somewhere to store password reset and magic link tokens. This can be a dedicated table with:
idemailoruserIdtoken(random string)type('PASSWORD_RESET' | 'MAGIC_LINK')expiresAtusedAt(nullable)createdAt
Example using Prisma (for illustration):
model User {
id String @id @default(cuid())
email String @unique
password String?
// other fields...
tokens Token[]
}
model Token {
id String @id @default(cuid())
user User? @relation(fields: [userId], references: [id])
userId String?
email String
token String @unique
type String // "PASSWORD_RESET" or "MAGIC_LINK"
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
For a minimal setup, you can store tokens in any database of your choice; the pattern remains the same.
Step 3: Generate secure tokens
Use a cryptographically secure random token. In Node/Next.js, you can use crypto:
// lib/tokens.ts
import crypto from 'crypto';
export function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
// Example: 64-character hex token (256 bits)
You’ll use this to create tokens for both password resets and magic links.
Step 4: Create a shared email sender using Resend
To avoid duplication, create a helper to send emails using Resend. You can send plain HTML or use React email components.
Basic example:
// lib/email-actions.ts
import { resend, EMAIL_FROM, APP_URL } from './resend';
import { generateToken } from './tokens';
type TokenType = 'PASSWORD_RESET' | 'MAGIC_LINK';
interface CreateTokenParams {
email: string;
type: TokenType;
}
// TODO: Replace with your DB client (e.g. Prisma, Drizzle, etc.)
async function saveTokenToDb(email: string, token: string, type: TokenType) {
const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15 minutes
// Example with Prisma:
// await prisma.token.create({
// data: { email, token, type, expiresAt },
// });
return { token, expiresAt };
}
export async function sendPasswordResetEmail(email: string) {
const token = generateToken();
await saveTokenToDb(email, token, 'PASSWORD_RESET');
const resetUrl = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;
await resend.emails.send({
from: EMAIL_FROM,
to: email,
subject: 'Reset your password',
html: `
<p>Click the link below to reset your password:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>This link will expire in 15 minutes. If you didn’t request this, you can ignore this email.</p>
`,
});
}
export async function sendMagicLinkEmail(email: string) {
const token = generateToken();
await saveTokenToDb(email, token, 'MAGIC_LINK');
const magicUrl = `${APP_URL}/magic-login?token=${encodeURIComponent(token)}`;
await resend.emails.send({
from: EMAIL_FROM,
to: email,
subject: 'Log in to your account',
html: `
<p>Click the link below to log in:</p>
<p><a href="${magicUrl}">${magicUrl}</a></p>
<p>This link will expire in 15 minutes and can only be used once.</p>
`,
});
}
In a production app, replace the saveTokenToDb placeholder with your actual DB operations and ensure that:
- Old tokens are cleaned up periodically.
- Only one active token per user per type is allowed (optional but recommended).
Step 5: Create API routes (App Router) to request emails
With the Next.js App Router, API routes live under app/api. Let’s create:
app/api/auth/request-password-reset/route.tsapp/api/auth/request-magic-link/route.ts
Password reset request route
// app/api/auth/request-password-reset/route.ts
import { NextResponse } from 'next/server';
import { sendPasswordResetEmail } from '@/lib/email-actions';
// TODO: Replace with your actual DB lookup
async function findUserByEmail(email: string) {
// Example: return await prisma.user.findUnique({ where: { email } });
return { id: 'dummy', email }; // Replace with real lookup
}
export async function POST(req: Request) {
try {
const { email } = await req.json();
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
const user = await findUserByEmail(email);
// To prevent user enumeration, respond with success even if user doesn't exist
if (user) {
await sendPasswordResetEmail(email);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Request password reset error:', error);
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
);
}
}
Magic link request route
// app/api/auth/request-magic-link/route.ts
import { NextResponse } from 'next/server';
import { sendMagicLinkEmail } from '@/lib/email-actions';
// TODO: Replace with your actual DB lookup
async function findUserByEmail(email: string) {
// Example: return await prisma.user.findUnique({ where: { email } });
return { id: 'dummy', email }; // Replace with real lookup
}
export async function POST(req: Request) {
try {
const { email } = await req.json();
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
const user = await findUserByEmail(email);
// Optionally: auto-create users for magic link flow
if (!user) {
// create user or silently ignore
// await prisma.user.create({ data: { email } });
}
await sendMagicLinkEmail(email);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Request magic link error:', error);
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
);
}
}
These routes can be called from your client-side forms using fetch or a mutation hook.
Step 6: Build the password reset page and API handler
You need two pieces:
- A page at
/reset-passwordthat:- Reads the token from the query string.
- Shows a form to set a new password.
- A server action or API route that:
- Validates the token (exists, not expired, not used).
- Updates the user password.
- Marks the token as used.
Password reset page (App Router)
// app/reset-password/page.tsx
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useState } from 'react';
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
if (!token) {
return <p>Invalid or missing token.</p>;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
setError(null);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to reset password');
}
setStatus('success');
// Optionally redirect after a short delay
setTimeout(() => router.push('/login'), 2000);
} catch (err: any) {
setStatus('error');
setError(err.message);
}
};
return (
<main className="max-w-md mx-auto py-8">
<h2 className="text-xl font-semibold mb-4">Reset your password</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1 text-sm font-medium">New password</label>
<input
type="password"
className="w-full border rounded px-3 py-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={status === 'loading'}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{status === 'loading' ? 'Resetting...' : 'Reset password'}
</button>
</form>
{status === 'success' && (
<p className="mt-4 text-green-600">Password updated! Redirecting…</p>
)}
{status === 'error' && error && (
<p className="mt-4 text-red-600">{error}</p>
)}
</main>
);
}
Password reset API route
// app/api/auth/reset-password/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
// TODO: Replace with your DB client
// import { prisma } from '@/lib/prisma';
async function findToken(token: string, type: string) {
// Example with Prisma:
// return prisma.token.findUnique({ where: { token }, include: { user: true } });
return null; // Replace with real lookup
}
async function markTokenUsed(id: string) {
// Example:
// await prisma.token.update({ where: { id }, data: { usedAt: new Date() } });
}
async function updateUserPassword(userId: string, password: string) {
// Hash password (e.g., bcrypt) and update user
const hashed = crypto.createHash('sha256').update(password).digest('hex');
// Example:
// await prisma.user.update({ where: { id: userId }, data: { password: hashed } });
}
export async function POST(req: Request) {
try {
const { token, password } = await req.json();
if (!token || typeof token !== 'string' || !password || typeof password !== 'string') {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
const dbToken: any = await findToken(token, 'PASSWORD_RESET');
if (!dbToken) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
if (dbToken.usedAt) {
return NextResponse.json({ error: 'Token already used' }, { status: 400 });
}
if (new Date(dbToken.expiresAt) < new Date()) {
return NextResponse.json({ error: 'Token expired' }, { status: 400 });
}
if (!dbToken.userId) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
await updateUserPassword(dbToken.userId, password);
await markTokenUsed(dbToken.id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Reset password error:', error);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
}
Replace the placeholder DB functions with your actual database logic and use a real password hashing algorithm (e.g., bcrypt or argon2) instead of crypto.createHash.
Step 7: Build the magic link callback route
For magic links, you’ll usually:
- Validate the token (similar to password reset).
- Mark it as used.
- Create a session or cookie.
- Redirect the user to a logged-in page (e.g.,
/dashboard).
Magic login page (token handler)
You can implement this as a server component that runs on the server, validates the token, sets cookies, and redirects.
// app/magic-login/page.tsx
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
// TODO: Replace with your DB and session logic
// import { prisma } from '@/lib/prisma';
async function validateMagicToken(token: string) {
// Example:
// const dbToken = await prisma.token.findUnique({
// where: { token },
// include: { user: true },
// });
// return dbToken;
return null; // Replace with real lookup
}
async function markTokenUsed(id: string) {
// await prisma.token.update({ where: { id }, data: { usedAt: new Date() } });
}
export default async function MagicLoginPage({
searchParams,
}: {
searchParams: { token?: string };
}) {
const token = searchParams.token;
if (!token) {
// Optionally show an error or redirect
redirect('/login?error=missing_token');
}
const dbToken: any = await validateMagicToken(token);
if (
!dbToken ||
dbToken.type !== 'MAGIC_LINK' ||
dbToken.usedAt ||
new Date(dbToken.expiresAt) < new Date()
) {
redirect('/login?error=invalid_or_expired_link');
}
if (!dbToken.userId) {
redirect('/login?error=invalid_user');
}
// Create a session. This is pseudo-code; use your auth/session library.
const sessionToken = `session-${dbToken.userId}-${Date.now()}`;
cookies().set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
await markTokenUsed(dbToken.id);
// Redirect to dashboard or any internal page
redirect('/dashboard');
}
In a real app, integrate with a session/auth library (NextAuth, Lucia, custom JWTs, etc.) rather than a raw session cookie, but this shows where the magic happens.
Step 8: Frontend forms for password reset and magic link requests
Finally, build simple forms that call the API routes.
Password reset request form (e.g., /forgot-password)
// app/forgot-password/page.tsx
'use client';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
setError(null);
try {
const res = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error('Failed to send reset email');
}
setStatus('sent');
} catch (err: any) {
setStatus('error');
setError(err.message);
}
};
return (
<main className="max-w-md mx-auto py-8">
<h2 className="text-xl font-semibold mb-4">Forgot your password?</h2>
<p className="mb-4 text-sm text-gray-600">
Enter your email and we’ll send you a link to reset your password.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1 text-sm font-medium">Email</label>
<input
type="email"
className="w-full border rounded px-3 py-2"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={status === 'loading'}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{status === 'loading' ? 'Sending...' : 'Send reset link'}
</button>
</form>
{status === 'sent' && (
<p className="mt-4 text-green-600">
If an account exists for that email, we’ve sent a password reset link.
</p>
)}
{status === 'error' && error && (
<p className="mt-4 text-red-600">{error}</p>
)}
</main>
);
}
Magic link login form (e.g., /magic-login-request)
// app/magic-login-request/page.tsx
'use client';
import { useState } from 'react';
export default function MagicLoginRequestPage() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
setError(null);
try {
const res = await fetch('/api/auth/request-magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error('Failed to send magic link');
}
setStatus('sent');
} catch (err: any) {
setStatus('error');
setError(err.message);
}
};
return (
<main className="max-w-md mx-auto py-8">
<h2 className="text-xl font-semibold mb-4">Log in with a magic link</h2>
<p className="mb-4 text-sm text-gray-600">
Enter your email and we’ll send you a one-time login link.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block mb-1 text-sm font-medium">Email</label>
<input
type="email"
className="w-full border rounded px-3 py-2"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={status === 'loading'}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{status === 'loading' ? 'Sending...' : 'Send magic link'}
</button>
</form>
{status === 'sent' && (
<p className="mt-4 text-green-600">
If an account exists for that email, we’ve sent a magic login link.
</p>
)}
{status === 'error' && error && (
<p className="mt-4 text-red-600">{error}</p>
)}
</main>
);
}
Security best practices for password reset and magic link emails
When you integrate Resend with Next.js (App Router) for password reset and magic link emails, keep these security details in mind:
- Short expiration: 10–30 minutes is typical for these tokens.
- One-time usage: Mark tokens as used after the first successful request.
- No user enumeration: API responses should not reveal whether an email exists.
- Strong randomness: Use
crypto.randomBytesor a similar cryptographically secure generator. - HTTPS only: Ensure your
APP_URLuseshttpsin production so links are secure. - Rate limiting: Consider throttling requests to
/request-password-resetand/request-magic-link. - Email content: Clearly state why the user received the email and what to do if they didn’t initiate the request.
Troubleshooting common issues
When wiring up Resend with the Next.js App Router for password reset and magic link flows, developers often run into a few repeated issues:
-
Emails not sending
- Check
RESEND_API_KEYandEMAIL_FROMin.env.local. - Ensure you’re using a verified sending domain in Resend.
- Log the result of
resend.emails.sendto see any errors.
- Check
-
Links not working (404 or wrong domain)
- Verify
APP_URLand that routes like/reset-passwordand/magic-loginexist. - Ensure you URL-encode tokens with
encodeURIComponent.
- Verify
-
Token always invalid or expired
- Confirm you’re saving the token exactly as generated.
- Check that your database time and server time are in sync.
- Make sure you filter by
type(PASSWORD_RESETvsMAGIC_LINK) if you store both in the same table.
-
Session not persisting after magic link
- Double-check cookie options (
domain,path,secure,httpOnly). - Make sure the cookie is set in a server component (like the example) or in a server route, not from the client.
- Double-check cookie options (
By following these steps, you can integrate Resend with Next.js (App Router) for password reset and magic link emails in a clean, modular way that’s ready for production. Swap in your own database, password hashing, and session management, and you’ll have a modern, secure email-based authentication flow tailored to your app.