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 (
userandadmin) - Sessions kept in a cookie
Two files define the whole setup:
lib/auth.tsis the server side. This is thebetterAuth({...})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.tsis 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:
| Plugin | From | What it does |
|---|---|---|
admin | better-auth/plugins | Adds the role field and user banning; sets a custom bannedUserMessage |
emailOTP | better-auth/plugins | Emails the 6-digit verification code at sign-up |
stripe | @better-auth/stripe | Subscriptions and webhooks, covered in Billing |
nextCookies | better-auth/next-js | Sets 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:
| Helper | What 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:
| Route | Form | Method it calls |
|---|---|---|
/auth/sign-in | sign-in-form.tsx | signIn.email + Google login |
/auth/register | register-form.tsx | signUp.email + Google login |
/auth/forgot-password | forgot-password-form.tsx | authClient.requestPasswordReset |
/auth/reset-password | reset-password-form.tsx | authClient.resetPassword (reads ?token= from the URL) |
/auth/verify-email | verify-email-form.tsx | authClient.emailOtp.verifyEmail (plus resend) |
/auth/error | error-content.tsx | none (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:
- Right after a new user row is created, the
databaseHooks.user.create.afterhook inlib/auth.tscallsauth.api.sendVerificationOTP, which emails a 6-digit code, not a link. - 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. - Because
emailVerification.autoSignInAfterVerificationistrue, 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.sendVerificationOTP | A 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.sendResetPassword | A user asks to reset their password |
user.changeEmail.sendChangeEmailConfirmation | A user changes their email, sent to the current address for approval |
user.deleteUser.sendDeleteAccountVerification | A 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 incomponents/users/columns.tsxdoes 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 viagetSession, in the browser viauseSession), or use therequireAdmin()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.
| Schema | Form | Main rules |
|---|---|---|
registerSchema | Register | name 1–32 chars, email ≤254, password 8–128 |
loginSchema | Sign-in | valid email + non-empty password |
forgotPasswordSchema | Forgot password | valid email |
verifyEmailOtpSchema | Verify email | exactly 6 digits |
resetPasswordSchema | Reset password | password + confirm must match |
changePasswordSchema | Change password | current + 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
| Variable | Used in | Purpose |
|---|---|---|
BETTER_AUTH_URL | lib/auth.ts | Base URL on the server |
NEXT_PUBLIC_BETTER_AUTH_URL | lib/auth-client.ts | Base URL in the browser |
BETTER_AUTH_SECRET | lib/auth.ts | Signing secret, generate with pnpm dlx @better-auth/cli secret |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | lib/auth.ts | Google 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.
Customizing the Theme
How to customize the theme in a Next.js app: change brand colors, corner radius, fonts, and dark mode through the Tailwind CSS v4 variables in globals.css.
Onboarding
How the post-signup onboarding wizard works in Next Starter: capture a profile and plan, then gate the dashboard behind the User.onboardingComplete flag.