
How do I integrate Resend with Next.js (App Router) for password reset and magic link emails?
Integrating Resend with a Next.js App Router project is a clean way to send password reset and magic link emails without maintaining your own SMTP infrastructure. With the App Router (app/ directory), you can expose secure server-side email endpoints and keep your authentication flows fully typed and maintainable.
Below is a step‑by‑step guide to help you understand how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l, from environment configuration to sending real emails from your Next.js app.
1. Prerequisites
Before you start, make sure you have:
- A Next.js 13+ project using the App Router (
app/directory) - Node.js 18+ (recommended)
- A Resend account and API key
- Basic familiarity with:
- Server Components vs Client Components
- Route handlers in
app/api/*/route.ts - Next.js environment variables
If you don’t have Resend set up yet, go to https://resend.com, create a project, and grab your API key from the dashboard.
2. Install and Configure Resend in Next.js
First, install the Resend SDK:
npm install resend
# or
yarn add resend
Add your Resend API key to your environment variables:
# .env.local
RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM="Your App <no-reply@yourdomain.com>"
NEXT_PUBLIC_APP_URL=https://your-app-url.com
Use
NEXT_PUBLIC_APP_URLso you can reliably build absolute URLs for password reset and magic links.
Create a reusable Resend client in your project, for example:
// 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);
3. Designing the Auth Flow: Tokens & Links
For both password reset and magic link flows, you need a secure token system and a way to generate URLs that users can click from their email.
3.1. Common token requirements
- Random and unique (e.g.,
crypto.randomUUID()orcrypto.randomBytes) - Short expiry (e.g., 15–30 minutes)
- Stored server-side:
- In a database (e.g., PostgreSQL, Prisma, Mongo)
- Or a key-value store (e.g., Redis, Upstash, KV)
- Tied to:
- The user’s email
- The token type (e.g.,
"reset_password","magic_link") - Expiration timestamp
A simple Prisma schema example:
model AuthToken {
id String @id @default(cuid())
token String @unique
email String
type String // 'reset_password' | 'magic_link'
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
}
3.2. Generating a secure token
In a Next.js server environment, you can do something like:
// lib/tokens.ts
import crypto from 'crypto';
import { prisma } from './prisma';
type TokenType = 'reset_password' | 'magic_link';
export async function createAuthToken(email: string, type: TokenType) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 1000 * 60 * 15); // 15 minutes
await prisma.authToken.create({
data: {
token,
email,
type,
expiresAt,
},
});
return token;
}
export async function useAuthToken(token: string, type: TokenType) {
const record = await prisma.authToken.findUnique({ where: { token } });
if (!record || record.type !== type || record.used || record.expiresAt < new Date()) {
return null;
}
await prisma.authToken.update({
where: { token },
data: { used: true },
});
return record;
}
4. Sending a Password Reset Email with Resend
The password reset flow typically has two parts:
- An API endpoint to request a reset (sends the email)
- A page where the user lands to set a new password (uses the token)
4.1. Password reset request endpoint (App Router)
Create a route handler:
// app/api/auth/password-reset/request/route.ts
import { NextResponse } from 'next/server';
import { resend } from '@/lib/resend';
import { createAuthToken } from '@/lib/tokens';
// import { prisma } from '@/lib/prisma'; // if you need to verify user exists
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const EMAIL_FROM = process.env.EMAIL_FROM!;
export async function POST(req: Request) {
const { email } = await req.json();
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
// Optionally check if the user exists:
// const user = await prisma.user.findUnique({ where: { email } });
// if (!user) { return NextResponse.json({ success: true }); }
const token = await createAuthToken(email, 'reset_password');
const resetUrl = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to: email,
subject: 'Reset your password',
html: `
<p>You requested a password reset.</p>
<p>Click the link below to set a new password (valid for 15 minutes):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>If you did not request this, you can safely ignore this email.</p>
`,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Resend error:', error);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}
This is the core of how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l when you want users to trigger a reset email from your frontend.
4.2. Password reset page (App Router)
Create a page that reads the token from the URL and lets the user submit a new password.
// app/reset-password/page.tsx
import ResetPasswordForm from './ResetPasswordForm';
export default function ResetPasswordPage({
searchParams,
}: {
searchParams: { token?: string };
}) {
const token = searchParams.token;
if (!token) {
return <p>Invalid or missing token.</p>;
}
return <ResetPasswordForm token={token} />;
}
An example client component form:
// app/reset-password/ResetPasswordForm.tsx
'use client';
import { useState } from 'react';
export default function ResetPasswordForm({ token }: { token: string }) {
const [password, setPassword] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus('loading');
const res = await fetch('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
setStatus('success');
} else {
setStatus('error');
}
}
if (status === 'success') return <p>Password updated successfully.</p>;
return (
<form onSubmit={handleSubmit}>
<label className="block mb-2">
New password
<input
type="password"
className="border px-2 py-1 w-full"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<button
type="submit"
disabled={status === 'loading'}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{status === 'loading' ? 'Updating…' : 'Update password'}
</button>
{status === 'error' && <p className="text-red-600 mt-2">Failed to update password.</p>}
</form>
);
}
Confirm route handler:
// app/api/auth/password-reset/confirm/route.ts
import { NextResponse } from 'next/server';
import { useAuthToken } from '@/lib/tokens';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export async function POST(req: Request) {
const { token, password } = await req.json();
if (!token || !password) {
return NextResponse.json({ error: 'Missing data' }, { status: 400 });
}
const record = await useAuthToken(token, 'reset_password');
if (!record) {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
}
const hashed = await bcrypt.hash(password, 12);
await prisma.user.update({
where: { email: record.email },
data: { passwordHash: hashed },
});
return NextResponse.json({ success: true });
}
5. Sending Magic Link Emails with Resend
Magic link login works similarly, but instead of resetting a password you log the user in when they click the link.
5.1. Magic link request endpoint
// app/api/auth/magic-link/request/route.ts
import { NextResponse } from 'next/server';
import { resend } from '@/lib/resend';
import { createAuthToken } from '@/lib/tokens';
import { prisma } from '@/lib/prisma';
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const EMAIL_FROM = process.env.EMAIL_FROM!;
export async function POST(req: Request) {
const { email } = await req.json();
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
// Ensure the user exists, or create them (depending on your flow)
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Optional: auto-register user
user = await prisma.user.create({ data: { email } });
}
const token = await createAuthToken(email, 'magic_link');
const magicUrl = `${APP_URL}/auth/magic?token=${encodeURIComponent(token)}`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to: email,
subject: 'Your magic login link',
html: `
<p>Click the link below to log in:</p>
<p><a href="${magicUrl}">${magicUrl}</a></p>
<p>This link is valid for 15 minutes and can only be used once.</p>
`,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Resend error:', error);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}
This aligns directly with how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l when magic links are involved.
5.2. Magic link verification route (App Router)
Create a page used only for the redirect logic:
// app/auth/magic/page.tsx
import { redirect } from 'next/navigation';
import { useAuthToken } from '@/lib/tokens';
import { createSession } from '@/lib/session';
export default async function MagicLinkPage({
searchParams,
}: {
searchParams: { token?: string };
}) {
const token = searchParams.token;
if (!token) {
redirect('/login?error=invalid_magic_link');
}
const record = await useAuthToken(token, 'magic_link');
if (!record) {
redirect('/login?error=invalid_or_expired_magic_link');
}
// Create a session for the user
await createSession(record.email);
redirect('/dashboard'); // or wherever you want to send the user after login
}
You’ll need a session helper that sets cookies:
// lib/session.ts
import { cookies } from 'next/headers';
import { prisma } from './prisma';
import crypto from 'crypto';
export async function createSession(email: string) {
const token = crypto.randomBytes(32).toString('hex');
await prisma.session.create({
data: {
token,
email,
// add expiry, etc.
},
});
cookies().set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
6. Using React Email (Optional) for Better Templates
Resend works well with React Email to build JSX-powered email templates.
Install React Email:
npm install react-email
Create a template:
// emails/ResetPasswordEmail.tsx
import * as React from 'react';
export function ResetPasswordEmail({ resetUrl }: { resetUrl: string }) {
return (
<div>
<h1>Reset your password</h1>
<p>Click the link below to reset your password:</p>
<p>
<a href={resetUrl}>{resetUrl}</a>
</p>
<p>If you didn’t request this, you can ignore this email.</p>
</div>
);
}
Use it with Resend:
// app/api/auth/password-reset/request/route.ts
import { render } from '@react-email/render';
import { ResetPasswordEmail } from '@/emails/ResetPasswordEmail';
// inside try block
await resend.emails.send({
from: EMAIL_FROM,
to: email,
subject: 'Reset your password',
html: render(<ResetPasswordEmail resetUrl={resetUrl} />),
});
React Email gives you a more maintainable and testable way to manage multiple templates for password reset and magic link flows.
7. Securing Your Resend + Next.js Integration
To keep how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l secure and robust:
- Never expose
RESEND_API_KEYto the client; only use it in server code and route handlers. - Validate input on all API routes (email, token, password).
- Use HTTPS in production so tokens and sessions are not exposed.
- Add rate limiting for email request endpoints to avoid abuse.
- Avoid leaking whether a user exists by always returning generic success responses for password reset requests.
- Keep token lifetimes short and single-use.
- Log errors from Resend, but don’t expose them in API responses.
8. Putting It All Together
To summarize how-do-i-integrate-resend-with-next-js-app-router-for-password-reset-and-magic-l:
- Set up Resend: Install the SDK, configure
RESEND_API_KEY, and create a shared client. - Build token logic: Create secure, expiring tokens stored in your database or KV.
- Create route handlers:
/api/auth/password-reset/requestto send reset emails./api/auth/password-reset/confirmto update the password./api/auth/magic-link/requestto send magic login links.
- Build UI pages:
/reset-passwordpage for submitting the new password./auth/magicpage to verify the magic link and create a session.
- Optionally use React Email for rich, reusable templates.
- Harden security: Rate limit, validate inputs, hide user existence, and secure sessions.
With this structure, you get a clean Next.js App Router integration, leveraging Resend for reliable password reset and magic link emails while keeping your auth logic in one coherent, typesafe place.