saasprokit
Authentication

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=); signUpEmailAction normalizes again before calling auth.api.signUpEmail, which is what Better Auth persists. Do not treat the client trim as the security boundary.
  • callbackURL is where the user lands after they verify, not after submitting the form. The action stores resolveCallbackUrl(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 of signUp.email; the client does not send it explicitly.
  • autoSignInAfterVerification: true — when the user clicks the link, Better Auth verifies the token, sets User.emailVerified = true, establishes a session, and redirects to the stored callbackURL (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)

Submit name + email + password formAction({ ...values, callbackUrl }) auth.api.signUpEmail({ email, password, name, callbackURL }) Create User (emailVerified = false) + Account sendVerificationEmail({ verificationUrl }) ok redirect /auth/verify-email?email=... GET verification link mark emailVerified = true, create Session (autoSignIn) redirect to callbackURL User opens the email User SignUpForm signUpEmailAction(server) Better Authauth.api.* @repo/email Email inbox /org

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/email and 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.tsxSignUpForm: dispatches signUpEmailAction, signInSocialAction, redirect
  • apps/web/app/[locale]/auth/sign-up/actions.tssignUpEmailAction
  • apps/web/app/[locale]/auth/actions.tssignInSocialAction (shared with sign-in)
  • apps/web/app/[locale]/auth/verify-email/page.tsx — server page; reads ?email= server-side
  • apps/web/app/[locale]/auth/verify-email/components/verify-email-card.tsx — resend card + cooldown + alternative-method links
  • apps/web/app/[locale]/auth/verify-email/actions.tsresendVerificationEmailFormAction
  • packages/auth/server.tsemailAndPassword + emailVerification config

On this page