Prompt

How do I send a password reset email from a next.js app?

Technology / Developer tools5 observationsLast seen Oct 20, 2026

Latest observation

Oct 20, 2026ChatGPTWeb search: on

You said:

How do I send a password reset email from a next.js app?

ChatGPT said:

The usual way to send a password reset email from a Next.js app is to create a password-reset API endpoint, generate a secure, time-limited token, and send a link to the user's email address using an email provider such as Resend, SendGrid, or Postmark.

The key point is that you should never email the user's existing password. Instead, email a one-time link that lets them choose a new password.

How the flow works

  1. User requests a reset

They enter their email address on /forgot-password.

  1. Next.js generates a token

Generate a cryptographically secure random token, store only its hash in your database, and set an expiration time.

  1. Send the reset email

The email contains a URL such as https://yourapp.com/reset-password?token=....

  1. Validate and reset

The user chooses a new password. Your server validates the token, hashes the new password, updates the account, and invalidates the token.

I'll show an example using the Next.js App Router, TypeScript, Resend, and Prisma. You can adapt the database operations to your existing setup.

1. Install Resend and configure environment variables

Install the email SDK:

Bash

npm install resend

Create a Resend API key and verify your sending domain using the Resend dashboard. Resend also provides a test sender for initial experiments. See the Next.js integration guide.

Add these to .env.local:

envRESEND_API_KEY=re_your_api_key APP_URL=http://localhost:3000

In production, set APP_URL to your actual HTTPS domain. Keep the API key server-side; don't prefix it with NEXT_PUBLIC_.

2. Create the database model

For example, with Prisma, add a model for reset tokens:

prismamodel PasswordResetToken { id String @id @default(cuid()) userId String tokenHash String @unique expiresAt DateTime createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) }

Add the corresponding relation to your existing User model:

prismapasswordResetTokens PasswordResetToken[]

Then run your Prisma migration. This assumes your User model already exists and has an email field.

3. Create the forgot-password API endpoint

Create app/api/forgot-password/route.ts.

This example uses prisma from your database client module and assumes your users have a unique email field.

TypeScript

import { randomBytes, createHash } from "node:crypto";
import { Resend } from "resend";
import { prisma } from "@/lib/prisma";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const email =
      typeof body.email === "string"
        ? body.email.trim().toLowerCase()
        : "";

    if (!email || email.length > 254) {
      return Response.json(
        { error: "Invalid email address" },
        { status: 400 }
      );
    }

    const user = await prisma.user.findUnique({
      where: { email },
    });

    // Return the same response whether the account exists or not.
    if (user) {
      const token = randomBytes(32).toString("hex");

      const tokenHash = createHash("sha256")
        .update(token)
        .digest("hex");

      const expiresAt = new Date(Date.now() + 15 * 60 * 1000);

      await prisma.$transaction([
        prisma.passwordResetToken.deleteMany({
          where: { userId: user.id },
        }),
        prisma.passwordResetToken.create({
          data: {
            userId: user.id,
            tokenHash,
            expiresAt,
          },
        }),
      ]);

      const resetUrl = new URL(
        "/reset-password",
        process.env.APP_URL
      );
      resetUrl.searchParams.set("token", token);

      const { error } = await resend.emails.send({
        from: "My App <no-reply@yourdomain.com>",
        to: user.email,
        subject: "Reset your password",
        text: [
          "We received a request to reset your password.",
          "",
          `Reset your password: ${resetUrl.toString()}`,
          "",
          "This link expires in 15 minutes.",
          "If you didn't request this, you can ignore this email.",
        ].join("\n"),
      });

      if (error) {
        // Log the error server-side; never log the token or reset URL.
        console.error("Password reset email failed:", error);

        // In production, also handle or clean up the pending token.
      }
    }

    return Response.json({
      message:
        "If an account exists for that email, a reset link will be sent.",
    });
  } catch {
    return Response.json(
      { error: "Unable to process request" },
      { status: 500 }
    );
  }
}

This uses a 32-byte cryptographically secure token, stores only its SHA-256 hash, and expires it after 15 minutes. Next.js App Router supports POST handlers in route.ts files under app/api. Next.js+1

Before using this in production:

  • Replace no-reply@yourdomain.com with a sender on your verified domain.
  • Add rate limiting by IP and email to prevent abuse.
  • Keep the response identical for existing and nonexistent accounts to reduce account enumeration.
  • Avoid logging reset links or raw tokens.
  • Consider queueing email delivery so provider failures don't leave confusing reset states.

4. Build the reset-password endpoint

The email link opens /reset-password?token=.... When the user submits their new password, create app/api/reset-password/route.ts.

Install a password-hashing library:

Bash

npm install argon2

Then implement the endpoint:

TypeScript

import { createHash } from "node:crypto";
import argon2 from "argon2";
import { prisma } from "@/lib/prisma";

export async function POST(request: Request) {
  const body = await request.json();

  const token =
    typeof body.token === "string" ? body.token : "";
  const password =
    typeof body.password === "string" ? body.password : "";

  if (!/^[a-f0-9]{64}$/.test(token)) {
    return Response.json({ error: "Invalid or expired link" }, {
      status: 400,
    });
  }

  if (password.length < 12 || password.length > 128) {
    return Response.json({
      error: "Password must be between 12 and 128 characters",
    }, { status: 400 });
  }

  const tokenHash = createHash("sha256")
    .update(token)
    .digest("hex");

  const passwordHash = await argon2.hash(password);

  try {
    const result = await prisma.$transaction(async (tx) => {
      const reset = await tx.passwordResetToken.findUnique({
        where: { tokenHash },
      });

      if (!reset || reset.expiresAt <= new Date()) {
        return false;
      }

      // Consume the token once; reject if already consumed.
      const consumed = await tx.passwordResetToken.deleteMany({
        where: {
          id: reset.id,
          tokenHash,
          expiresAt: { gt: new Date() },
        },
      });

      if (consumed.count !== 1) {
        return false;
      }

      await tx.user.update({
        where: { id: reset.userId },
        data: { passwordHash },
      });

      // Invalidate any other outstanding reset links.
      await tx.passwordResetToken.deleteMany({
        where: { userId: reset.userId },
      });

      return true;
    });

    if (!result) {
      return Response.json(
        { error: "Invalid or expired link" },
        { status: 400 }
      );
    }

    return Response.json({
      message: "Password reset successfully",
    });
  } catch {
    return Response.json(
      { error: "Unable to reset password" },
      { status: 500 }
    );
  }
}

This assumes your User model has a passwordHash field. Adapt the field name to your schema.

Also, invalidate existing sessions after a successful reset if your authentication system supports it. For high-security applications, use appropriate transaction isolation and test concurrent token use against your database.

5. Call the endpoint from your form

Your forgot-password form can submit to the API like this:

TypeScript

async function requestPasswordReset(email: string) {
  const response = await fetch("/api/forgot-password", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
  });

  if (!response.ok) {
    throw new Error("Unable to process your request");
  }

  return response.json();
}

For the reset page, read the token from the URL and submit it with the new password:

TypeScript

async function resetPassword(
  token: string,
  password: string
) {
  const response = await fetch("/api/reset-password", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ token, password }),
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error ?? "Password reset failed");
  }

  return data;
}

Make sure the page displays success or failure messages and never assumes the password was changed until the server confirms it.

Which approach should you use?

Custom implementation

Use the approach above if you already manage users, passwords, and sessions in your own database.

Auth provider

If you use Auth.js, Clerk, Supabase Auth, or Auth0, check its password-reset workflow first. The provider can handle much of the token and account-security logic for you.

One question so I can tailor the code: What are you currently using for authentication?

Custom authentication + databaseAuth.js / NextAuthSupabase AuthClerkOther / not set up yet

Which Next.js router are you using?

App Router (app/)

Pages Router (pages/)

Observation history

How did Obsurfable measure this prompt?

Obsurfable records AI answers to buyer-style prompts in its research corpus (5 observations for this page). Metrics are distributions over observations, not a single static ranking.

Which AI systems does Obsurfable collect answers from?

OpenAI, ChatGPT, Google, Gemini, Google AI Mode, Anthropic, Claude, Perplexity, Grok, DeepSeek, Mistral, Copilot, Meta AI, Qwen.

How can brands improve their AEO?

Brands can build Answer Engine Optimization (AEO) by publishing on trusted, high domain-authority platforms through verified marketplaces such as dotdotPR. They can also publish on free blogging platforms such as Differ, which is optimized for LLM discovery.

Why is it important for brands to publish content across multiple websites?

Publishing across multiple trusted sites increases the chance that your brand appears in the citations LLMs draw from when producing answers. Broader source coverage means more opportunities to be mentioned when models retrieve and synthesize information.

Want this interpreted for your brand?

Explorer is the free public corpus. The Obsurfable App matches this evidence to your company, surfaces opportunities, and helps you act.