Next Starter Logo

Authentication

How Next Starter handles authentication with Better Auth — email/password and Google sign-in, email OTP verification, password resets, roles, and sessions.

How it works

Authentication is the part of the app that knows who a user is and what they're allowed to do. Next Starter handles it with Better Auth, storing users and sessions in Prisma/PostgreSQL.

It covers six things:

  • Email and password sign-up and sign-in
  • Google login (OAuth, meaning you sign in with an account you already have elsewhere)
  • Email verification with a 6-digit code (OTP, a one-time passcode)
  • Password reset by email link
  • Roles (user and admin)
  • Sessions kept in a cookie

Two files define the whole setup:

  • lib/auth.ts is the server side. This is the betterAuth({...}) call: the database adapter, session settings, the email/password rules, Google login, plugins, and the email hooks. It runs on the server only.
  • lib/auth-client.ts is the browser side (createAuthClient). It exports the methods and hooks (signIn, signUp, useSession, and so on) that your React components import.

Every auth request goes through one API route: app/api/auth/[...all]/route.ts, which calls toNextJsHandler(auth). Sign-in, Google callbacks, OTP checks, and Stripe webhooks all flow through it. There is no middleware.ts. Route protection happens in layouts instead (see Protecting routes).

The server and client share three plugins (admin, emailOTP, stripe). When you add a plugin, you usually add its server half in lib/auth.ts and its client half in lib/auth-client.ts.

Plugins

Plugins add features to Better Auth. They live in the plugins array of lib/auth.ts:

PluginFromWhat it does
adminbetter-auth/pluginsAdds the role field and user banning; sets a custom bannedUserMessage
emailOTPbetter-auth/pluginsEmails the 6-digit verification code at sign-up
stripe@better-auth/stripeSubscriptions and webhooks, covered in Billing
nextCookiesbetter-auth/next-jsSets the session cookie for the App Router. Must stay last in the array.

The browser client adds the matching halves: adminClient(), emailOTPClient(), and stripeClient({ subscription: true }).

Sessions

A session is the record that keeps a user logged in. Next Starter stores sessions in the database (the Session table) and gives the browser an HTTP-only cookie (a cookie JavaScript can't read, which is safer).

To cut database reads, session.cookieCache keeps a copy of the session inside the cookie for 5 minutes. Change maxAge in lib/auth.ts to make that window longer or shorter.

In client components, read the session with the useSession hook:

"use client";
import { useSession } from "@/lib/auth-client";

const { data: session, isPending } = useSession();

In server components, use the helpers in lib/server/auth-helpers.ts. They wrap auth.api.getSession in React's cache(), so the lookup runs at most once per request:

HelperWhat it returns
getSession()The session, or null if signed out
requireSession()The session, or throws AuthError if signed out
requireAdmin()The session if role === "admin", else throws AuthError
getUserPlan()The active plan: "free", "plus", or "pro"

Protecting routes

There's no middleware. You guard pages in layouts instead. A layout runs before the pages inside it, so it can redirect before anything renders.

app/dashboard/layout.tsx sends away anyone who isn't signed in or hasn't finished onboarding:

const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (!session.user.onboardingComplete) redirect("/onboarding");

The nested app/dashboard/(admin)/layout.tsx adds a role check on top: if (session.user.role !== "admin") redirect("/dashboard").

To protect a new section, give it a layout that calls getSession() (or requireSession / requireAdmin) and redirects. Put admin-only pages inside an (admin) route group so they share one role check.

Auth pages

All sign-in and sign-up pages live under app/auth/ and share app/auth/layout.tsx (form on the left, gradient panel on the right). Each page is a thin wrapper around a client form that calls one Better Auth method:

RouteFormMethod it calls
/auth/sign-insign-in-form.tsxsignIn.email + Google login
/auth/registerregister-form.tsxsignUp.email + Google login
/auth/forgot-passwordforgot-password-form.tsxauthClient.requestPasswordReset
/auth/reset-passwordreset-password-form.tsxauthClient.resetPassword (reads ?token= from the URL)
/auth/verify-emailverify-email-form.tsxauthClient.emailOtp.verifyEmail (plus resend)
/auth/errorerror-content.tsxnone (shows mapped error messages)

The error page turns Better Auth error codes (INVALID_EMAIL_OR_PASSWORD, USER_ALREADY_EXISTS, PASSWORD_COMPROMISED, TOO_MANY_REQUESTS, banned) into friendly text. Better Auth sends users here on its own, set by onAPIError.errorURL: "/auth/error" in lib/auth.ts.

Email verification & password reset

Email verification is required. emailAndPassword.requireEmailVerification is true, so a new user can't sign in until they confirm their address. The flow:

  1. Right after a new user row is created, the databaseHooks.user.create.after hook in lib/auth.ts calls auth.api.sendVerificationOTP, which emails a 6-digit code, not a link.
  2. The user types it in on /auth/verify-email. Registering redirects here with ?sent=true, which starts a 30-second cooldown on the resend button since a code just went out.
  3. Because emailVerification.autoSignInAfterVerification is true, they're signed in as soon as the code checks out.

The verify-email form also has a Resend code button (authClient.emailOtp.sendVerificationOtp) for codes that expired or never arrived.

All these emails are sent by sendEmail from lib/email/, which renders React Email templates. The hooks in lib/auth.ts that trigger them:

Hook (in lib/auth.ts)Fires when
emailOTP.sendVerificationOTPA verification code is requested, at sign-up (via the user.create.after hook) or on resend. It only handles the email-verification code type and skips already-verified users
emailAndPassword.sendResetPasswordA user asks to reset their password
user.changeEmail.sendChangeEmailConfirmationA user changes their email, sent to the current address for approval
user.deleteUser.sendDeleteAccountVerificationA user asks to delete their account

Skipping already-verified users on the send keeps it enumeration-safe: the endpoint returns the same success whether or not the account exists, so nobody can probe which emails are registered.

emailAndPassword.revokeSessionsOnPasswordReset is true, so resetting a password logs out every other session. user.deleteUser.beforeDelete blocks deletion while a subscription is active, then removes the user's subscription and verification rows and their R2 avatar files.

OAuth providers

OAuth lets users sign in with an account they already have, like Google. Google is set up under socialProviders in lib/auth.ts, using GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. The button in components/auth/google-signin-button.tsx starts the flow:

await signIn.social({ provider: "google", callbackURL: "/dashboard" });

The button also passes errorCallbackURL (default /auth/sign-in), so a failed Google login lands back on the sign-in page. That page reads the account_not_linked error and shows a clear message when someone tries Google on an email that already has a password account.

To add another provider (GitHub, Discord, and so on): add it to socialProviders with its own env-var credentials, then call signIn.social({ provider: "github" }) from a button. The full list is in the Better Auth social providers docs.

Roles

A role decides what a user can do. The admin plugin adds a role field, and two roles ship by default: user (the default for new accounts) and admin.

  • Promote a user (you need an existing admin session): authClient.admin.setRole({ userId, role: "admin" }). The admin table in components/users/columns.tsx does this.
  • First admin: set it straight in the database with UPDATE "user" SET role = 'admin' WHERE email = '…'.
  • Check a role: session.user.role === "admin" (on the server via getSession, in the browser via useSession), or use the requireAdmin() helper.

To add more roles, set up the admin plugin's access-control options in lib/auth.ts. See the Better Auth admin docs.

Blocking disposable emails

New sign-ups can't use throwaway email domains. The databaseHooks.user.create.before hook in lib/auth.ts reads the email's domain, checks it against lib/email/blocked-domains.json, and rejects a match with a BAD_REQUEST error. This runs on the server during account creation, so it can't be bypassed from the browser. It only applies to email/password sign-ups (context.path === "/sign-up/email"), not Google logins. To change which domains are blocked, edit that JSON file.

The auth forms themselves don't run a captcha. Cloudflare Turnstile guards the contact form only. See the Turnstile page for how that's set up.

Validation schemas

The forms check input with Zod schemas (lib/validations/auth.ts) through zodResolver. This is only for fast feedback in the browser. Better Auth checks everything again on the server, so it can't be bypassed.

SchemaFormMain rules
registerSchemaRegistername 1–32 chars, email ≤254, password 8–128
loginSchemaSign-invalid email + non-empty password
forgotPasswordSchemaForgot passwordvalid email
verifyEmailOtpSchemaVerify emailexactly 6 digits
resetPasswordSchemaReset passwordpassword + confirm must match
changePasswordSchemaChange passwordcurrent + new + confirm must match

To add a field: change the schema here, then the form component, then the Better Auth call if it's a new credential field. The password rule (8–128) is shared, so editing it once updates both register and reset.

Environment variables

VariableUsed inPurpose
BETTER_AUTH_URLlib/auth.tsBase URL on the server
NEXT_PUBLIC_BETTER_AUTH_URLlib/auth-client.tsBase URL in the browser
BETTER_AUTH_SECRETlib/auth.tsSigning secret, generate with pnpm dlx @better-auth/cli secret
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETlib/auth.tsGoogle OAuth credentials

Set both base URLs to your app's origin (for example http://localhost:3000 while developing). See Environment variables for the full list.

On this page