Prompt
How do I send a password reset email from a next.js app?
Latest observation
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
- User requests a reset
They enter their email address on /forgot-password.
- Next.js generates a token
Generate a cryptographically secure random token, store only its hash in your database, and set an expiration time.
- Send the reset email
The email contains a URL such as https://yourapp.com/reset-password?token=....
- 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.comwith 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/)