The auth UI is owned by src/features/auth/components. Route files stay thin and render feature components for sign in, sign up, forgot password, and reset password.
Route Pages
Auth uses individual route pages for each user facing flow.
export default async function SignInPage({ searchParams }: SignInPageProps) {
return (
<Suspense fallback={<Spinner centered />}>
<SignInComponent searchParams={searchParams} />
</Suspense>
)
}
async function SignInComponent({ searchParams }: SignInPageProps) {
const resolvedSearchParams = await searchParams
const user = await getSessionUser()
if (user) {
const safeNext = getSafeNextFromSearchParamsRecord(resolvedSearchParams)
redirect(safeNext ?? WebRoutes.dashboard.path)
}
return <Auth activeTab="sign-in" />
}The public routes are:
/sign-in/sign-up/forgot-password/reset-password
Each route owns its own metadata, redirect behavior, and canonical URL. The shared Auth shell still receives an internal activeTab value so the same feature component can render the right form and preserve callback URLs.
Sign In Methods
Sign in supports:
- Google OAuth.
- Magic link by email.
- Email and password.
- Passkey.
The sign in form lives in src/features/auth/components/sign-in/sign-in-form.tsx.
const result = await signInWithEmailAndPasswordAction({
email,
password,
callbackURL: buildCallbackUrl(),
embedded: true,
})If the account has two factor authentication enabled, the server action returns a two factor required response. The UI then lets Better Auth continue the verification flow.
Sign Up Methods
Sign up supports:
- Google OAuth.
- Magic link by email.
- Email and password.
The sign up form lives in src/features/auth/components/sign-up/sign-up-form.tsx. Email and password signup creates the user, uses the configured callback URL, and signs the user into the dashboard when the flow succeeds.
await auth.api.signUpEmail({
body: {
name: "User",
email,
password,
callbackURL: callbackURL ?? ApiRoutes.authSignedIn,
},
headers: await headers(),
})Better Auth also creates the Stripe customer on signup through the Stripe plugin configured in auth.ts.
Google OAuth
Google OAuth is started by signInWithGoogleAction.
await auth.api.signInSocial({
body: {
provider: "google",
callbackURL,
},
headers: await headers(),
})The setup guide covers the Google Cloud credentials. The important rule is that the authorized redirect URI must match the Better Auth callback route for the current domain.
http://localhost:3000/api/auth/callback/google
https://your-domain.com/api/auth/callback/googleIf the auth base path changes, update the redirect URI in Google Cloud as well.
Magic Links
Magic link sign in is handled by the Better Auth magic link plugin.
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendMagicLinkEmail({
to: email,
url,
})
},
})The UI shows a check your email state after the link is sent. The email content is sent through the email feature, so template copy belongs in the email layer.
Password Reset
Password reset is enabled in auth.ts and uses the email feature to send reset links.
resetPassword: {
enabled: true,
}The request form lives in src/features/auth/components/forgot-password/forgot-password-form.tsx. The reset form lives in src/features/auth/components/reset-password/reset-password-form.tsx.
Validation lives in src/features/auth/lib/auth.schema.ts.
export const resetPasswordSchema = z
.object({
newPassword: passwordSchema,
confirmPassword: passwordSchema,
})
.refine((data) => data.newPassword === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
})Account Reactivation
Users with a deactivated account can reactivate during sign in after their credentials are verified. The server side logic lives in src/features/auth/lib/auth.repository.ts, and the confirmation UI lives in src/features/auth/components/sign-in/sign-in-reactivate-dialog.tsx.
This keeps deactivation reversible without exposing account state changes to unauthenticated users.