Sign Up & Email Verification
Registering with email/password or social, and the mandatory email-verification gate before first sign-in
Registration happens at /auth/sign-up. A new user can register with email + password or with a social provider (Google/GitHub). Because the auth instance sets requireEmailVerification: true, an email/password user must verify their address before they can sign in — so the sign-up flow ends not at the dashboard but at a "check your email" screen.
The sign-up client
apps/web/app/[locale]/auth/sign-up/components/sign-up-form.tsx (the SignUpForm component) dispatches signUpEmailAction through useActionState; the auth.api.signUpEmail call happens server-side:
const [state, formAction, isPending] = useActionState(
signUpEmailAction,
initialFormState
);
onSubmit={form.handleSubmit((values) =>
startTransition(() => formAction({ ...values, callbackUrl }))
)}
// on success, route to the "check your email" screen
const email = form.getValues("email").toLowerCase().trim();
router.push(`${PAGE_ROUTES.VERIFY_EMAIL}?email=${encodeURIComponent(email)}`);Two details worth noting:
- Email is normalized (
toLowerCase().trim()) in two places: the client does it only for the verify-email redirect query string (?email=);signUpEmailActionnormalizes again before callingauth.api.signUpEmail, which is what Better Auth persists. Do not treat the client trim as the security boundary. callbackURLis where the user lands after they verify, not after submitting the form. The action storesresolveCallbackUrl(input.callbackUrl)with the verification token — default/org, or a prior destination if?callbackUrl=was present and safe. On a successful submit the client redirects to/auth/verify-email; the stored callback is consumed when the emailed link is clicked.
Social sign-up uses the same signInSocialAction(provider, callbackUrl) as sign-in — there is no separate "social sign-up" endpoint, and auth pages do not import @repo/auth/client:
async function handleSocialLogin(provider: SocialProvider) {
const result = await signInSocialAction(provider, callbackUrl);
if (result.success && result.data?.url) window.location.href = result.data.url;
}Social accounts are pre-verified by the provider, so they skip the email-verification gate entirely and go straight to callbackURL.
Why verification is mandatory
The gate is configured in packages/auth/server.ts:
emailAndPassword: {
enabled: true,
requireEmailVerification: true, // can't sign in until verified
// ...
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await sendEmailVerificationEmail({ email: user.email, userName: user.name, verificationUrl: url });
},
sendOnSignUp: true, // fire the email automatically on registration
autoSignInAfterVerification: true, // clicking the link signs them in
},sendOnSignUp: true— the verification email is sent automatically as part ofsignUp.email; the client does not send it explicitly.autoSignInAfterVerification: true— when the user clicks the link, Better Auth verifies the token, setsUser.emailVerified = true, establishes a session, and redirects to the storedcallbackURL(resolved, default/org). The user does not have to return to the sign-in page.
The verify-email page
/auth/verify-email is the screen the user lands on after registering. verify-email/page.tsx reads the ?email= query param server-side and passes it as a prop, so the colocated VerifyEmailCard (components/verify-email-card.tsx) shows which address the link went to without a client useSearchParams round-trip. The card resends via a useActionState server action and keeps a 60-second cooldown as local UI state:
// verify-email/page.tsx — read the address server-side
const [{ locale }, { email }] = await Promise.all([params, searchParams]);
return <VerifyEmailCard email={email ?? null} errors={dict.errors.action} />;
// components/verify-email-card.tsx — Recipe B: the address rides in a hidden input
const [state, formAction, isPending] = useActionState(
resendVerificationEmailFormAction,
initialState
);
<form action={formAction}>
<input name="email" type="hidden" value={email} />
<Button disabled={isPending || cooldown > 0} type="submit">
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend verification email"}
</Button>
</form>The 60-second cooldown is pure UI state: one effect starts it on a successful resend, a second ticks it down once per second.
The page also offers two alternative sign-in methods (magic link, email OTP) for users who don't want to wait on the verification email. Better Auth's default for those passwordless methods is to treat a successful sign-in as verifying the address; this app does not implement a separate side-effect for that.
The alternative-method buttons link to /auth/sign-in?tab=magic-link and /auth/sign-in?tab=otp. The sign-in page reads the tab searchParam through resolveTab() and passes it to <Tabs defaultValue>, so these links open directly on the matching tab (see Sign In).
End-to-end sequence (email/password)
Notes
- Resend uses the query-param email verbatim, so a tampered
?email=would resend to that address — but only ever sends a verification email for an account that exists, which leaks nothing actionable. - A user who tries to sign in before verifying is rejected by
requireEmailVerification; the verify-email page (and its resend) is the recovery path. - Because verification is rate-limited (
/sign-up/emailand the verification endpoints), the 60-second client cooldown is UX sugar on top of a real server-side limit.
Source files
apps/web/app/[locale]/auth/sign-up/page.tsx— server page (loads dictionary)apps/web/app/[locale]/auth/sign-up/components/sign-up-form.tsx—SignUpForm: dispatchessignUpEmailAction,signInSocialAction, redirectapps/web/app/[locale]/auth/sign-up/actions.ts—signUpEmailActionapps/web/app/[locale]/auth/actions.ts—signInSocialAction(shared with sign-in)apps/web/app/[locale]/auth/verify-email/page.tsx— server page; reads?email=server-sideapps/web/app/[locale]/auth/verify-email/components/verify-email-card.tsx— resend card + cooldown + alternative-method linksapps/web/app/[locale]/auth/verify-email/actions.ts—resendVerificationEmailFormActionpackages/auth/server.ts—emailAndPassword+emailVerificationconfig