- Published on
Password Recovery with Next.js 14 and Supabase
Introduction
In this post, we’ll walk through how to implement a password recovery system using Next.js 14 and Supabase. We'll cover setting up an API route to handle the password reset link, and client-side pages for users to request a password reset and set a new password.
Step 1: Configure Supabase Client
Ensure you have the Supabase client set up using the pkce flow. We’ll use it in our API routes and client-side code.
// supabase/pkce.ts
import { createBrowserClient } from "@supabase/ssr";
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
detectSessionInUrl: true,
flowType: "pkce",
},
}
);
Step 2: Create the Confirmation API Route
This API route will handle the confirmation link sent to the user’s email. When a user clicks on the link they will receive via email, they will get redirected to this api route that verifies their token and redirects them to the password reset page.
// pages/api/auth/confirm.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { absoluteUrl } from "@/lib/utils/absoluteUrl";
import { Routes } from "@/types";
export async function GET(req: Request) {
const supabase = createClient();
const url = new URL(req.url);
const token_hash = url.searchParams.get("token_hash");
const type = url.searchParams.get("type");
if (!token_hash || type !== "email") {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Here is were we verify the token_hash so that our users
// will later be able to update their password.
const {
data: { session },
error,
} = await supabase.auth.verifyOtp({ token_hash, type });
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
// Redirect to the specified URL which in our case is
// the app/password-reset/page.tsx, where users will set the new password
// after they have been authenticated using the verifyOtp method
return NextResponse.redirect(absoluteUrl(Routes.passwordReset));
}
Step 3: Set up api route to send an email to the user
Before we allow users to update their password, we need to generate a password reset link using Supabase that Supabase will email to our users. For production sites, it's best to set up a custom SMTP server.
// /api/reset-password/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { BASE_URL } from "@/constants";
const options = {
redirectTo: BASE_URL, // https://your-site.com
};
export async function POST(req: Request) {
const supabase = createClient();
const { email } = await req.json();
if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
try {
// Generate a password reset link using Supabase and email it to the user
const { data, error } = await supabase.auth.resetPasswordForEmail(
email,
options
);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error });
}
}
Step 4: Set Up the Email Template in Supabase
Now we need to set up the email template to point to an api route in our Next.js application. In the Supabase Dashboard, navigate to the "Authentication" settings and configure the reset password
email template to point to your confirmation endpoint.
Notice the /api/auth/confirm?
part, that points to the api route we set up in Step 2:
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #333; min-height:100vh">
<h2 style="color: #4A90E2;">Password Recovery</h2>
<p>Follow this link to reset the password for your user:</p>
<p><a href="{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=email&redirectUrl={{ .RedirectTo }}">Reset Password</a></p>
<p>If you did not request this, please ignore this email.</p>
</div>
Step 5: Password Reset Client-Side Implementation
Create a page where users can actually set their new password. If all goes well, on the api route code, we will be redirected there. Here we use zod and useForm, but this is not essential for this to work.
// app/password-reset/page.tsx
"use client";
import * as z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { supabase } from "@/lib/supabase/pkce";
import { Routes } from "@/types";
export const PasswordReset = () => {
const [pending, startTransition] = useTransition();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
password: "",
},
});
const handleSetPassword = (data: z.infer<typeof FormSchema>) => {
const parsedData = FormSchema.shape.password.safeParse(data.password);
if (!parsedData.success) {
toast.error("Please use at least 8 characters for your password.");
return;
}
startTransition(async () => {
try {
// here we update the password using the updateUser method
const { error } = await supabase.auth.updateUser({
password: parsedData.data,
});
if (error) {
console.error("Password error:", error);
toast.error("Something went wrong. Please try again.");
return;
}
toast.success("Password updated successfully 🎉");
} catch (error) {
console.error("Form error:", error);
toast.error("Something went wrong. Please try again.", {
duration: 3000,
});
return;
}
// redirect if we are successful
window.location.href = Routes.home;
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSetPassword)} className="flex flex-col gap-4 mx-auto">
<FormField
control={form.control}
name="password"
render={({ field }) => {
return (
<FormItem>
<FormLabel className="font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-xl tracking-wide leading-loose">
New Password
</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="8+ characters"
autoComplete={"8+ characters"}
disabled={pending}
aria-disabled={pending}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
<Button type="submit">Reset Password</Button>
</form>
</Form>
);
};
export default PasswordReset;
Step 6: Utility Functions and Types
Ensure you have necessary utility functions and types in place.
// lib/utils/absoluteUrl.ts
export const absoluteUrl = (path: string) => `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
// types/index.ts
export const Routes = {
home: "/",
passwordReset: "/reset-password",
// ...rest of the routes
};
export enum APIRoutes {
passwordRecoveryEmail = "/api/reset-password",
// ... rest of the api routes
}
Step 7: Client-Side Code for Requesting Password Reset
This component will allow users to input their email address and request a password reset. Supabase will then send an email with the reset link.
// we will import and render this component in --> app/password-recovery/page.tsx
"use client";
import * as z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { APIRoutes, Routes } from "@/types";
import { useTransition } from "react";
import { LoaderCircleIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { SuccessMessage } from "./success-message";
import { useRedirect } from "@/lib/hooks/useRedirect";
import { useInvalidTokenError } from "@/lib/hooks/useInvalidTokenError";
import { absoluteUrl } from "@/lib/utils/absoluteUrl";
const FormSchema = z
.object({
email: z.string().email(),
})
.strict();
type Props = {
buttonText: string;
headingText: string;
paragraphText: string;
emailAutoComplete: string;
};
export const PasswordRecoveryForm = ({
buttonText,
headingText,
paragraphText,
emailAutoComplete,
}: Props) => {
const router = useRouter();
const [pending, startTransition] = useTransition();
const { showSuccessMessage, setShowSuccessMessage } = useRedirect(
router,
5000
);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: "",
},
});
const handleResetPassword = async (data: z.infer<typeof FormSchema>) => {
const parsedData = FormSchema.shape.email.safeParse(data.email);
if (!parsedData.success) {
toast.error("Invalid email. Please check your input.");
return;
}
startTransition(async () => {
try {
const response = await fetch(APIRoutes.passwordRecoveryEmail, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: parsedData.data,
}),
});
if (!response.ok) {
toast.error("Something went wrong. Please try again.", {
duration: 5000,
});
} else {
setShowSuccessMessage(true);
}
} catch (error) {
console.error("Reset password error:", error);
toast.error("Something went wrong. Please try again.", {
duration: 5000,
});
}
});
};
if (showSuccessMessage) {
return <SuccessMessage />;
}
return (
<div className="bg-white p-12 flex flex-col justify-center mx-auto">
<div className="mt-12">
<h2 className="text-3xl font-bold mb-4">{headingText}</h2>
<p className="text-gray-600 mb-8">{paragraphText}</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleResetPassword)}>
<div className="space-y-4 mb-4">
<FormField
control={form.control}
name="email"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="johndoe@example.com"
type="email"
autoComplete={emailAutoComplete}
{...field}
disabled={pending}
aria-disabled={pending}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex items-center space-x-4 mb-4 mx-auto">
<Button
className="bg-[#bd1e59] text-white relative flex-1 text-base"
type="submit"
disabled={pending}
aria-disabled={pending}
>
{pending && (
<LoaderCircleIcon className="h-5 w-5 text-white animate-spin absolute left-36" />
)}
{buttonText}
</Button>
<Link
href={absoluteUrl(Routes.login)}
prefetch
className="flex-1 text-center text-base text-blue-500 hover:underline"
>
Return to login
</Link>
</div>
</form>
</Form>
</div>
</div>
);
};
Conclusion
With these steps, you've set up a complete password recovery system using Next.js 14 and Supabase.
Users can request a password reset, receive an email with a link, and set a new password through the provided form.
This approach ensures a secure and user-friendly password recovery flow in Next.js.
Feel free to customize the UI and enhance the functionality as needed. Happy coding!