In our previous guide, we set up Better Auth with Nodemailer to handle email verification and invites. Today, we are tackling a critical feature for any production app: The "Forgot Password" flow.
While Magic Links (links that log you in or reset your password immediately) are popular, they force the user to leave your app, open their email, and click a link that opens a new tab.
Sometimes, you want to keep the user right where they are. In this guide, we will build an OTP (One-Time Password) reset flow. The user enters their email, receives a 6-digit code, and resets their password without ever closing your tab.
The Backend Config
Good news: If you followed the previous guide, your backend is already ready.
The emailOTP plugin we installed previously handles the forget-password type automatically. When we call the forgot password function from the client, Better Auth will trigger the sendVerificationOTP hook we defined in auth.ts with the type set to "forget-password".
Just ensure your email.ts template handles the subject line dynamically (or generic enough) for password resets.
The Client-Side Implementation
We need to handle two distinct stages in our UI:
- Request Stage: User enters email -> System sends OTP.
- Reset Stage: User enters OTP + New Password -> System updates credentials.
1. The Logic Hooks
Instead of dumping 200 lines of UI code, let's look at the core functions using authClient.
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
export const usePasswordReset = () => {
const [stage, setStage] = useState<"request" | "reset">("request");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Step 1: Send the OTP
const sendResetOtp = async (email: string) => {
setIsLoading(true);
setError(null);
const { error } = await authClient.forgetPassword.emailOtp({
email,
redirectTo: "/dashboard", // Optional: strictly for flow control
});
setIsLoading(false);
if (error) {
setError(error.message);
return false;
}
setStage("reset"); // Move to next UI stage
return true;
};
// Step 2: Validate OTP and Set New Password
const resetPassword = async (email: string, otp: string, password: string) => {
setIsLoading(true);
setError(null);
const { error } = await authClient.emailOtp.resetPassword({
email,
otp,
password,
});
setIsLoading(false);
if (error) {
setError(error.message);
return false;
}
return true; // Success!
};
return { stage, setStage, isLoading, error, sendResetOtp, resetPassword };
};
2. The UI Implementation
Here is how we compose the UI. I've simplified the styling to focus on the structure, but you can keep your Tailwind gradients for that premium feel.
The Request Form (Stage 1):
if (stage === "request") {
return (
<div className="container">
<h1>Forgot Password</h1>
<p>Enter your email to receive a verification code.</p>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
/>
<Button
onClick={() => sendResetOtp(email)}
disabled={isLoading || !email}
>
{isLoading ? "Sending Code..." : "Send OTP"}
</Button>
{error && <p className="text-red-500">{error}</p>}
</div>
);
}
The Reset Form (Stage 2):
if (stage === "reset") {
return (
<div className="container">
<h1>Reset Password</h1>
<p>Code sent to <span className="font-bold">{email}</span></p>
{/* OTP Input - limit to 6 numbers */}
<Label>OTP Code</Label>
<Input
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="tracking-widest text-center text-lg"
placeholder="000000"
/>
<Label>New Password</Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Label>Confirm Password</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button
onClick={() => {
if (newPassword === confirmPassword) {
resetPassword(email, otp, newPassword);
}
}}
disabled={isLoading}
>
{isLoading ? "Resetting..." : "Set New Password"}
</Button>
{/* Resend Logic */}
<button onClick={() => sendResetOtp(email)} className="text-sm underline mt-4">
Resend Code
</button>
</div>
);
}
Magic Link vs. OTP: Which one should you choose?
I chose OTP for implementation, but Better Auth support both. Here is a quick breakdown to help you decide which fits your app better.
The OTP Approach (This Guide)
Pros:
- Seamless Context: The user never leaves your application tab. This reduces "drop-off" rates.
- Mobile Friendly: Great for mobile apps where handling deep links (Universal Links) can be buggy or complex to set up.
- Perceived Security: Users are accustomed to 2FA codes; it feels secure to enter a code.
Cons:
- Friction: The user has to manually copy and paste (or type) a code.
- Typos: Users might mistype the code or the password, requiring error handling.
The Magic Link Approach
Pros:
- Zero Friction: One click and they are done.
- Simple: No need for the user to understand what a "code" is.
Cons:
- Context Switching: Forces the user to open a new browser window/tab.
- Email Scanners: aggresive enterprise email scanners sometimes "click" links to check for malware, which can accidentally invalidate one-time tokens before the user sees them.
Conclusion
By using the emailOTP plugin in Better Auth, we've created a password reset flow that keeps users engaged within our application. It's a small UX detail, but keeping users inside your app ecosystem always leads to higher conversion and retention.
for more info you can blog.
Top comments (0)