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

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

Where to next?