saasprokit
Authentication

Sign In

The tabbed sign-in page — password, social, magic link, and email OTP — plus the two-factor hand-off

/auth/sign-in is the busiest auth page: a single Tabs UI hosts four ways in. sign-in/page.tsx is a Server Component that hosts the Tabs shell, and three per-tab client components (components/{sign-in-form,magic-link-sign-in,email-otp-sign-in}.tsx) each dispatch a different server action through useActionState. The browser never calls Better Auth directly — the actions run auth.api.* server-side.

// page.tsx reads ?tab= and resolves it to the initial tab
<Tabs defaultValue={resolveTab(tab)}>
  <TabsTrigger value="password">…</TabsTrigger>
  <TabsTrigger value="magic-link">…</TabsTrigger>
  <TabsTrigger value="otp">…</TabsTrigger>
</Tabs>

Password (and social)

The password tab renders SignInForm, which also carries the Google/GitHub buttons.

// components/sign-in-form.tsx — RHF validates, then dispatches the action
const [state, formAction, isPending] = useActionState(
  signInEmailAction,
  initialFormState
);

onSubmit={form.handleSubmit((values) =>
  startTransition(() => formAction(values))
)}

useActionResult(state, {
  onSuccess: (data) => {
    if (data?.twoFactorRequired) {
      router.push(PAGE_ROUTES.TWO_FACTOR);
      return;
    }
    router.push(callbackUrl);
    router.refresh();
  },
});

// social stays a plain awaited call — it redirects to the provider
async function handleSocialLogin(provider: SocialProvider) {
  const result = await signInSocialAction(provider, callbackUrl);
  if (result.success && result.data?.url) window.location.href = result.data.url;
}
  • rememberMe is passed through to Better Auth, which controls whether the session cookie is persistent or session-scoped.
  • On success useActionResult calls router.push(callbackUrl) and router.refresh() — the refresh re-runs Server Components so the freshly minted session cookie is picked up immediately. Do not hand-roll a useEffect for this.
  • 2FA short-circuits the redirect: when the action returns data.twoFactorRequired, useActionResult routes to /auth/2fa instead and skips the refresh.
  • Social sign-in returns a provider URL that the handler assigns to window.location.href; control never returns to the form.

middleware.ts appends ?callbackUrl=<original path> when it bounces an unauthenticated user here. page.tsx resolves it through resolveCallbackUrl and passes the result to all three tabs, so a bounced user lands back where they were headed. The resolver rejects protocol-relative (//evil.com) and cross-origin targets, falling back to /org — so a crafted ?callbackUrl cannot turn this page into an open redirect. See Sessions & Route Protection.

The magic-link tab requests a one-time login link by email:

// components/magic-link-sign-in.tsx
const [state, formAction, isPending] = useActionState(
  signInMagicLinkAction,
  initialFormState
);
// dispatched as formAction({ ...values, callbackUrl })
// success → render <MagicLinkSentCard /> inline

The link is generated server-side by the magicLink() plugin and emailed via sendMagicLinkEmail. It expires in 10 minutes (MAGIC_LINK_EXPIRES_IN_MINUTES). Clicking it hits the plugin's verify endpoint, which establishes a session and redirects to the resolved callbackURL (default /org) via resolveCallbackUrl.

There used to be a dedicated /auth/magic-link landing page for the failure/expired-link state (driven by a ?error= param), but its page.tsx was removed. PAGE_ROUTES.MAGIC_LINK still exists in lib/routes.ts and is currently unused — the only live magic-link UI is the inline MagicLinkSentCard (sign-in/components/magic-link-sent-card.tsx) shown on this tab after a successful request.

Email OTP

The OTP tab is a two-step flow. EmailOtpSignIn holds the entered address in local state and swaps between two inner components, each with its own useActionState:

// Step 1 — EmailOtpRequest dispatches sendSignInOtpAction, then lifts the email
onSent(form.getValues("email").toLowerCase().trim());

// Step 2 — EmailOtpVerify dispatches verifySignInOtpAction with the code,
// carrying the lifted email as a ride-along value
formAction({ ...values, email });
// success → router.push(callbackUrl)

The code is 6 digits (OTP_LENGTH) and expires in 5 minutes (OTP_EXPIRES_IN_MINUTES). On the server, sendVerificationOTP switches on type — here "sign-in" routes to sendSignInOTPEmail. A "Use different email" button clears the lifted email to restart at step 1.

The two-factor hand-off

When a user with 2FA enabled signs in with a correct password, Better Auth does not return a full session — signInEmailAction returns data.twoFactorRequired, and the browser is sent to /auth/2fa before any session is granted. After a successful second factor the 2FA form always lands on /org (the original callbackUrl is dropped). See Two-Factor Authentication.

Method comparison

Password tab Social buttons Magic Link tab Email OTP tab no yes /auth/sign-in signInEmailAction signInSocialAction → provider signInMagicLinkAction → email link (10m) sendSignInOtpAction → verifySignInOtpAction (6 digit, 5m) 2FA enabled? callbackUrl (default /org) /auth/2fa (verifyTwoFactorAction) /org

Notes

  • The email-based methods normalize identically — password, magic link, and email OTP all apply toLowerCase().trim() before the call, so casing or whitespace can't fork an account across methods. Social sign-in delegates identity to the provider, so there's no email field to normalize.
  • Tab deep-links workpage.tsx reads the ?tab= searchParam through resolveTab() and passes it to <Tabs defaultValue>, so links to /auth/sign-in?tab=magic-link / ?tab=otp open directly on that tab (an unknown value falls back to password). The verify-email page's alternative-method buttons rely on this.
  • apps/web/app/[locale]/auth/layout.tsx redirects already-signed-in users away from /auth/sign-in to /org, so a logged-in user can't see this page. This check is session-verified via getServerSession() (cookie-cache eligible), not part of the edge middleware.ts gate — see Sessions & Route Protection.

Source files

  • apps/web/app/[locale]/auth/sign-in/page.tsx — server page; hosts the Tabs, resolves ?tab= and ?callbackUrl=
  • apps/web/app/[locale]/auth/sign-in/components/{sign-in-form,magic-link-sign-in,email-otp-sign-in}.tsx — the three per-tab client components
  • apps/web/app/[locale]/auth/sign-in/actions.tssignInEmailAction, signInMagicLinkAction, sendSignInOtpAction, verifySignInOtpAction
  • apps/web/app/[locale]/auth/actions.tssignInSocialAction (shared by sign-in and sign-up)
  • packages/auth/server.tsmagicLink and emailOTP plugin config
  • packages/auth/client.tsmagicLinkClient, emailOTPClient, twoFactorClient

On this page