How to Send Password Reset Emails
Build a password reset email flow with Next.js App Router and Notify.
Password reset is the classic transactional email. This guide uses the Next.js App Router: generate a secure token on the server, store a hash, and send the reset link with Notify.
Never call Notify from a Client Component — keep NOTIFY_API_KEY server-side.
Prerequisites
- API key
- Verified domain for production
fromaddresses - Next.js App Router project
NOTIFY_API_KEY=your_api_key_here
1. Request reset route
Create app/api/auth/forgot-password/route.ts. Validate the email, create a one-time token, persist a hash with an expiry, then send:
// app/api/auth/forgot-password/route.ts
import { createHash, randomBytes } from 'crypto';
export async function POST(request: Request) {
const { email } = await request.json();
if (!email || typeof email !== 'string') {
return Response.json({ error: 'email is required' }, { status: 400 });
}
// Look up the user in your DB. Always return a generic success
// response so you do not leak whether the address exists.
const user = await findUserByEmail(email);
if (user) {
const token = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await savePasswordResetToken({
userId: user.id,
tokenHash,
expiresAt
});
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`;
const response = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
to: email,
subject: 'Reset your password',
message: `
<h1>Reset your password</h1>
<p>We received a request to reset your password.</p>
<p><a href="${resetUrl}">Choose a new password</a></p>
<p>This link expires in one hour. If you did not request this, you can ignore this email.</p>
`
})
});
if (!response.ok) {
console.error('Notify error', await response.text());
return Response.json({ error: 'Failed to send email' }, { status: 500 });
}
}
return Response.json({
ok: true,
message: 'If that email exists, we sent a reset link.'
});
}
Replace findUserByEmail / savePasswordResetToken with your database layer (Supabase, Prisma, etc.).
2. Reset page (App Router)
// app/reset-password/page.tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
export default function ResetPasswordPage() {
const token = useSearchParams().get('token') ?? '';
const [password, setPassword] = useState('');
const [status, setStatus] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
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();
setStatus(data.message ?? data.error);
}
return (
<form onSubmit={onSubmit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="New password"
required
/>
<button type="submit">Update password</button>
{status && <p>{status}</p>}
</form>
);
}
3. Confirm reset route
// app/api/auth/reset-password/route.ts
import { createHash } from 'crypto';
export async function POST(request: Request) {
const { token, password } = await request.json();
if (!token || !password) {
return Response.json(
{ error: 'token and password are required' },
{ status: 400 }
);
}
const tokenHash = createHash('sha256').update(token).digest('hex');
const record = await findValidPasswordResetToken(tokenHash);
if (!record) {
return Response.json(
{ error: 'Invalid or expired reset link' },
{ status: 400 }
);
}
await updateUserPassword(record.userId, password);
await deletePasswordResetToken(record.id);
return Response.json({ message: 'Password updated' });
}
Deliverability tips
- Send from a verified domain
- Keep copy short; put the action link early
- Expire tokens (1 hour is a common default) and single-use them
- Watch logs and webhooks for bounces