The Supabase reset flow in the App Router
app/forgot-password/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
const SITE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://app.example.com, no trailing slash
export type ResetState = { sent: boolean; message: string };
export async function requestPasswordReset(_prev: ResetState, formData: FormData): Promise<ResetState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { sent: false, message: "Enter the email you signed up with." };
const supabase = await createClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
// Must be on your Redirect URLs list in Supabase. The email template below decides the real link.
redirectTo: `${SITE}/account/update-password`,
});
if (error?.code === "over_email_send_rate_limit") {
return { sent: false, message: "Too many reset emails for now. Wait a few minutes and try again." };
}
// Same answer whether or not the account exists. Supabase does the same, so nobody can probe for emails.
return { sent: true, message: "If that email has an account, a reset link is on its way. Check your inbox and spam." };
}
app/forgot-password/page.tsx
"use client";
import { useActionState } from "react";
import { requestPasswordReset, type ResetState } from "./actions";
const initial: ResetState = { sent: false, message: "" };
export default function ForgotPasswordPage() {
const [state, formAction, pending] = useActionState(requestPasswordReset, initial);
return (
<main className="mx-auto grid min-h-dvh max-w-sm content-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Reset your password</h1>
{state.sent ? (
<p role="status">{state.message}</p>
) : (
<form action={formAction} className="grid gap-3">
<label htmlFor="email" className="text-sm">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required className="rounded-md border px-3 py-2" />
{state.message ? <p role="alert" className="text-sm text-red-500">{state.message}</p> : null}
<button type="submit" disabled={pending} className="rounded-md bg-black px-3 py-2 text-white disabled:opacity-60">
{pending ? "Sending..." : "Send reset link"}
</button>
</form>
)}
<a href="/login" className="text-sm underline">Back to sign in</a>
</main>
);
}
Supabase dashboard: Authentication > Emails > Reset Password
<h2>Reset your password</h2>
<p>We received a request to reset your password. Follow the link below to choose a new one.</p>
<p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/account/update-password">Reset password</a>
</p>
app/auth/confirm/route.ts
import { type EmailOtpType } from "@supabase/supabase-js";
import { type NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
// The reset email links here: /auth/confirm?token_hash=...&type=recovery&next=/account/update-password
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const nextParam = searchParams.get("next") ?? "/";
// Only follow paths on this site, so the link can't be turned into an open redirect.
const next = nextParam.startsWith("/") && !nextParam.startsWith("//") ? nextParam : "/";
const redirectTo = request.nextUrl.clone();
redirectTo.search = "";
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({ type, token_hash });
if (!error) {
redirectTo.pathname = next; // the recovery session cookie is set now
return NextResponse.redirect(redirectTo);
}
}
redirectTo.pathname = "/auth/auth-code-error";
return NextResponse.redirect(redirectTo);
}
app/account/update-password/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import UpdatePasswordForm from "./form";
export default async function UpdatePasswordPage() {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
// No session means the link was not opened, or it was opened in another browser.
if (!data?.claims) redirect("/auth/auth-code-error");
return (
<main className="mx-auto grid min-h-dvh max-w-sm content-center gap-4 p-6">
<h1 className="text-2xl font-semibold">Choose a new password</h1>
<p className="text-sm opacity-70">For {data.claims.email}</p>
<UpdatePasswordForm />
</main>
);
}
app/account/update-password/form.tsx
"use client";
import { useActionState } from "react";
import { updatePassword, type UpdateState } from "./actions";
export default function UpdatePasswordForm() {
const [state, formAction, pending] = useActionState(updatePassword, { message: "" } satisfies UpdateState);
return (
<form action={formAction} className="grid gap-3">
<label htmlFor="password" className="text-sm">New password</label>
<input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required className="rounded-md border px-3 py-2" />
<label htmlFor="confirm" className="text-sm">Type it again</label>
<input id="confirm" name="confirm" type="password" autoComplete="new-password" minLength={8} required className="rounded-md border px-3 py-2" />
{state.message ? <p role="alert" className="text-sm text-red-500">{state.message}</p> : null}
<button type="submit" disabled={pending} className="rounded-md bg-black px-3 py-2 text-white disabled:opacity-60">
{pending ? "Saving..." : "Save new password"}
</button>
</form>
);
}
app/account/update-password/actions.ts
"use server";
import { isAuthSessionMissingError } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export type UpdateState = { message: string };
export async function updatePassword(_prev: UpdateState, formData: FormData): Promise<UpdateState> {
const password = String(formData.get("password") ?? "");
if (password.length < 8) return { message: "Use at least 8 characters." };
if (password !== formData.get("confirm")) return { message: "The two passwords don't match." };
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({ password });
if (error) {
if (isAuthSessionMissingError(error)) return { message: "Your reset link has expired. Ask for a new one." };
if (error.code === "same_password") return { message: "That's your current password. Pick a new one." };
if (error.code === "weak_password") return { message: error.message }; // lists what your password rules need
return { message: "Couldn't save the password. Try again." };
}
// The user is signed in with the new password now.
redirect("/dashboard");
}
app/auth/auth-code-error/page.tsx
export default function AuthCodeError() {
return (
<main className="mx-auto grid min-h-dvh max-w-sm content-center gap-3 p-6">
<h1 className="text-xl font-semibold">That link has expired or was already used</h1>
<p>Reset links work once and only for a limited time. Ask for a new one and open it in this browser.</p>
<a href="/forgot-password" className="underline">Send a new reset link</a>
</main>
);
}
lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
// One client per request. Same file as on the Supabase login page guide.
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// Called from a Server Component: safe to ignore when middleware refreshes the session
}
},
},
});
}