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
Next Starter runs authentication on Better Auth and keeps users and sessions in Prisma/PostgreSQL. What ships:
- Email and password sign-up and sign-in
- Google login
- Email verification with a 6-digit code
- Password reset by email link
- Roles (
userandadmin) - Sessions stored in the database, with a cookie cache
- One API key per user, managed from the Settings page
Two files hold the setup. lib/auth.ts is the server side: the betterAuth({...}) call with the database adapter, session settings, the email/password rules, Google login, plugins, and the email hooks. lib/auth-client.ts is the browser side, built with createAuthClient. It exports the methods and hooks your React components import: signIn, signUp, useSession, and so on.
Every auth request that arrives over HTTP 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 land there. Server-side code skips the route and calls auth.api directly.
There is no middleware.ts. Next.js 16 runs that kind of code from proxy.ts instead, and the product has one at the repo root, but all it does is check for a session cookie. The real enforcement happens in layouts, pages and Server Actions, covered under Protecting routes.
Plugins
Plugins 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; passes roles: defaultRoles and 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 |
apiKey | @better-auth/api-key | Per-user API keys, covered in API keys |
nextCookies | better-auth/next-js | Sets the session cookie for the App Router. Must stay last in the array. |
Four of them have a browser half registered in lib/auth-client.ts: adminClient(), emailOTPClient(), stripeClient({ subscription: true }), and apiKeyClient(). So when you add a plugin, check whether it needs both halves.
Sessions
Sessions live in the Session table, and the browser gets an HTTP-only cookie. To cut database reads, session.cookieCache keeps a copy of the session in a second cookie for 5 minutes. Widen or narrow that window by changing maxAge in lib/auth.ts.
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 all go through one cache()-wrapped call to auth.api.getSession, 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". Queries the Subscription table, so this one is not cached |
hasPastDueSubscription() | true if the user has a past_due subscription. Also queries the Subscription table |
Protecting routes
proxy.ts matches /dashboard/:path* and bounces anyone without a session cookie to sign-in. That exists to redirect early, before any layout or page renders. The file's own comment says it isn't a security boundary. getSessionCookie reads the cookie header and nothing else, so a cookie being present proves nothing. Three layers do the real work.
Layouts cover the shared case. 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").
Pages repeat the session check. Every dashboard page calls getSession() again, and the two admin pages re-test the role. That looks redundant and isn't. Next.js renders layouts and pages in parallel, and a shared layout doesn't re-run on client-side navigation, so a layout on its own won't catch every route change. The Next.js authentication guide says as much.
Server Actions check themselves. Anyone can POST to an action, whatever layout wraps the page that calls it. requireSession() and requireAdmin() exist for that case. Both throw AuthError, and the action catches it and returns an error instead. Don't reach for them in a layout, because nothing there catches the throw. Layouts and pages use getSession() with an explicit redirect().
To protect a new section, copy all three layers, and put admin-only pages inside an (admin) route group so they share one role check. The proxy.ts matcher lists only /dashboard/:path*, so a new section gets no early redirect until you add it there. Until then the layout does the redirecting, and you lose nothing in safety.
Auth pages
Every sign-in and sign-up page lives under app/auth/ and shares app/auth/layout.tsx. That layout drops the form into a card and, on large screens, adds a decorative panel beside it. Each page is a thin wrapper around a client form:
| 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. onAPIError.errorURL: "/auth/error" in lib/auth.ts is what points it there.
Email verification & password reset
emailAndPassword.requireEmailVerification is true, so a new email/password account can't sign in until the address is confirmed. Google users skip the whole flow, because Google already verified the address.
- Right after the user row is created, the
databaseHooks.user.create.afterhook inlib/auth.tscallsauth.api.sendVerificationOTP, which emails a 6-digit code rather than 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 code button, since sign-up already triggered a send. emailVerification.autoSignInAfterVerificationistrue, so they're signed in the moment the code checks out. The form then sends them on to/onboarding, covered in Onboarding.
That same requireEmailVerification flag changes what a duplicate sign-up looks like. Better Auth returns an ordinary-looking success instead of USER_ALREADY_EXISTS, so the endpoint never confirms which addresses are registered. The placeholder user it returns gets this app's own defaults (role, banned, banReason, banExpires, stripeCustomerId, onboardingComplete) from emailAndPassword.customSyntheticUser. And the verification send skips already-verified accounts for the same reason.
All of these emails go out through sendEmail from lib/email/, which renders React Email templates. Here are 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 |
emailAndPassword.sendResetPassword | A user asks to reset their password |
user.changeEmail.sendChangeEmailConfirmation | A user changes their email, sent to the current address for approval |
emailVerification.sendVerificationEmail | The current address approved the change, sent to the new address to confirm it. It returns early for unverified users, and sendOnSignUp is false, so sign-up never sends a link on top of the code |
user.deleteUser.sendDeleteAccountVerification | A user asks to delete their account |
hooks.after on /change-password | A signed-in user just changed their password. This one is a heads-up rather than a confirmation step, so it goes to the address already on file |
That last one lives in Better Auth's own hooks.after middleware, which doubles as the activity log. It writes a Pino line for sign-up, email verification, password reset request, password change, email change request, and OAuth sign-in, and those only fire on success. Sign-in is the exception. It logs failed attempts too. See Logging.
emailAndPassword.revokeSessionsOnPasswordReset is true, so a reset logs out every session that user has, the current one included. user.deleteUser.beforeDelete only blocks deletion while a subscription is active, trialing or past due. The cleanup lives in databaseHooks.user.delete, which also runs when an admin deletes the user: before removes the user's API keys, and after removes their subscription rows, the verification rows keyed to their user id, and makes a best-effort delete of any avatar they uploaded. The admin path gets the same subscription block from the hooks.before middleware on /admin/remove-user.
OAuth providers
Google sits under socialProviders in lib/auth.ts and reads GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. account.encryptOAuthTokens is true, so the tokens Google returns are encrypted in the Account table. The button in components/auth/google-signin-button.tsx starts the flow:
await signIn.social({
provider: "google",
callbackURL: callbackUrl,
errorCallbackURL: errorCallbackUrl,
});callbackUrl defaults to /dashboard and errorCallbackUrl to /auth/sign-in, so a failed Google login lands back on the sign-in page. That page reads the account_not_linked error. Better Auth links Google to an existing password account on its own once that account's email is verified, so you'll mostly see this error when the password account hasn't been verified yet.
To add another provider such as GitHub or Discord, 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
The admin plugin adds a role field, and two roles ship by default: user (the default for new accounts) and admin.
To promote a user, call authClient.admin.setRole({ userId, role: "admin" }) from an existing admin session. The admin table in components/users/columns.tsx does this. For the first admin, set the role straight in the database with UPDATE "user" SET role = 'admin' WHERE email = '…'.
To check a role, compare session.user.role === "admin", on the server via getSession and in the browser via useSession, or use the requireAdmin() helper.
To add more roles, extend the roles option passed to admin() in lib/auth.ts. It ships as defaultRoles. See the Better Auth admin docs.
API keys
The apiKey plugin from @better-auth/api-key gives every signed-in user one API key for their integrations. Keys live hashed in the Apikey model in prisma/schema.prisma (table apikey), with referenceId pointing at the user and start holding the first characters for display. The plugin options in lib/auth.ts:
defaultPrefix: "ns_", so keys minted from the settings page start withns_. A caller can still pass its ownprefixto/api-key/create.keyExpiration.disableCustomExpiresTime: true. Keys never expire, and a client can't passexpiresInto mint one that does.rateLimit: 120 requests per 60 seconds per key. Each key gets a copy of the limits at creation, so changing them affects new keys only.
The UI is the API Key card on /dashboard/settings, in components/settings/api-key-card.tsx. app/dashboard/settings/page.tsx renders it and loads the key's id, start, createdAt and lastRequest, never the hashed key column. Create key calls authClient.apiKey.create({}) and shows the full key once in components/settings/dialogs/show-api-key-dialog.tsx. Revoke key calls authClient.apiKey.delete({ keyId }) from revoke-api-key-dialog.tsx. The hooks.before middleware on /api-key/create enforces the one-key-per-user limit on the server, so a direct POST hits the same limit. And as covered above, deleting a user removes their keys.
Nothing in the starter consumes the keys yet. To accept one on your own endpoint, call auth.api.verifyApiKey({ body: { key } }). The plugin can also treat an x-api-key header as a session, but enableSessionForAPIKeys is off by default. Details are in the Better Auth API key docs.
Rate limiting
Better Auth throttles its own endpoints, but only in production by default, so lib/auth.ts sets rateLimit: { enabled: true } to keep the throttle on in development as well.
Better Auth keys the counters by client IP and request path and holds them in memory, so each server instance keeps its own tally. The IP comes from advanced.ipAddress.ipAddressHeaders, which reads cf-connecting-ip before x-forwarded-for, so behind Cloudflare every visitor keeps their own bucket. The credential paths already come throttled hard: 3 requests per 10 seconds on /sign-in, /sign-up, /change-password and /change-email, and 3 per minute on the password-reset request and OTP-send endpoints. Plugins add rules of their own, so the email OTP plugin also holds /email-otp/verify-email to 3 per minute. Everything else gets the general budget of 100 per 10 seconds. window and max change that budget, and customRules overrides a single path and beats the built-in rules. Switching to storage: "database" gives you one shared count across instances, but Better Auth only generates the rateLimit table when that option is set, so regenerate the schema and run a Prisma migration first. The options are in the Better Auth rate limit 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 the browser can't bypass it. It only applies to email/password sign-ups, where context.path === "/sign-up/email". Google logins skip it. 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 the Zod schemas in lib/validations/auth.ts through zodResolver. This is for fast feedback in the browser. Better Auth revalidates the email and password server-side under its own rules, but it doesn't know about these schemas, so the 1 to 32 character name limit and the confirm-password match are browser-only. Anything you actually need enforced, check again on the server.
| Schema | Form | Main rules |
|---|---|---|
registerSchema | Register | name 1 to 32 chars, email ≤254, password 8 to 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 non-empty, 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 of 8 to 128 characters is shared, so editing it once covers register, reset, change password, and both confirm fields. The email and name rules come from lib/validations/user.ts, where the profile and admin forms reuse them.
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 auth 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.