Authentication
How Next Starter implements authentication using Better Auth, covering email/password sign-up, Google OAuth, session management, role-based access control, and email verification wired to Prisma and your SMTP provider.
Overview
Next Starter uses Better Auth for all authentication. It handles sign-up, sign-in, email verification, password reset, OAuth, session management, and role-based access, all wired into the database via Prisma.
The auth configuration lives in two files:
lib/auth.ts: server-side configuration, plugins, and email hookslib/auth-client.ts: client-side hooks exported for use in components
Server Configuration
lib/auth.ts is where the Better Auth instance is created and exported. This file is server-only.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { admin } from "better-auth/plugins";
import { stripe } from "@better-auth/stripe";
import prisma from "@/lib/db";
import { env } from "@/lib/validations/env";
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5-minute client-side cache
},
},
rateLimit: { enabled: true },
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: `Reset your ${APP_CONFIG.name} password`,
html: getPasswordResetEmailHtml(user.name || "there", url),
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: false,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: user.name ? `Verify your email, ${user.name}` : "Verify your email",
html: getVerificationEmailHtml(user.name || "there", url),
});
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailVerification: async ({ user, newEmail, url }) => {
await sendEmail({
to: user.email, // Send to current email for approval
subject: "Approve your email change",
html: getEmailChangeVerificationHtml(user.name || "there", newEmail, url),
});
},
callbackURL: "/auth/sign-in?info=email-changed",
},
deleteUser: {
enabled: true,
sendDeleteAccountVerification: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Confirm account deletion",
html: getDeleteAccountVerificationHtml(user.name || "there", url),
});
},
beforeDelete: async (user) => {
// Checks for active subscriptions, cleans up related data, deletes avatar files
},
},
},
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
plugins: [
admin({
bannedUserMessage: "Your account has been banned. Please contact support for assistance.",
}),
stripe({ /* stripe config — see Billing page */ }),
nextCookies(), // Must be last plugin
],
onAPIError: {
errorURL: "/auth/error",
onError: (error) => {
logger.error({ event: "auth_api_error", err: error }, "Authentication API error occurred");
},
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});
export type Session = typeof auth.$Infer.Session;Key configuration details:
sendResetPasswordlives insideemailAndPasswordand sends the password reset email vialib/email.tsemailVerification.sendOnSignUpistrue: a verification email is sent automatically on registrationemailVerification.autoSignInAfterVerificationisfalse: after clicking the verification link, users are redirected to the sign-in page rather than being signed in automaticallyuser.changeEmailanduser.deleteUserare both enabled, each with their own email verification flowuser.deleteUser.beforeDeletechecks for active subscriptions (blocks deletion if any exist), cleans up related database records, and deletes avatar files from R2admin()receives a custombannedUserMessageshown to banned usersonAPIError.errorURLroutes auth errors to/auth/error, where error codes are mapped to user-readable messagesnextCookies()must always be listed last: it handles setting the session cookie correctly for Next.js App Router server components
Client Hooks
lib/auth-client.ts creates the client-side auth instance and re-exports named hooks for use in client components.
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
import { stripeClient } from "@better-auth/stripe/client";
import type { Session } from "@/lib/auth";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
plugins: [adminClient(), stripeClient({ subscription: true })],
});
export const {
signIn,
signOut,
signUp,
useSession,
getSession,
linkSocial,
changeEmail,
changePassword,
updateUser,
deleteUser,
listAccounts,
subscription,
} = authClient;
export const client = authClient;
export type { Session };The client alias and re-exported Session type are used across the codebase for convenience.
Sessions
Sessions are cookie-based and database-backed. When a user signs in, Better Auth creates a session record in the Session table and sets an HTTP-only cookie. The cookieCache option reduces database reads by caching session data on the client for 5 minutes.
Reading the Session in Client Components
Use the useSession hook in any client component:
"use client";
import { useSession } from "@/lib/auth-client";
export function ProfileButton() {
const { data: session, isPending } = useSession();
if (isPending) return <Skeleton />;
if (!session) return <SignInButton />;
return <span>{session.user.name}</span>;
}Reading the Session in Server Components
Use the cached getSession helper from lib/server/auth-helpers.ts. This wraps auth.api.getSession with React's cache() so the session is fetched at most once per request, even if called from multiple server components in the same tree.
// lib/server/auth-helpers.ts
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export const getSession = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});Use it in a server component or layout:
import { getSession } from "@/lib/server/auth-helpers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getSession();
if (!session) redirect("/auth/sign-in");
return <div>Hello, {session.user.name}</div>;
}Protecting Routes
Route protection is handled at the layout level. The dashboard layout checks for a session and redirects unauthenticated users:
// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }) {
const session = await getSession();
if (!session) redirect("/auth/sign-in");
return <>{children}</>;
}The nested (admin) layout adds a role check for admin-only sections:
// app/dashboard/(admin)/layout.tsx
export default async function AdminLayout({ children }) {
const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (session.user.role !== "admin") redirect("/dashboard");
return <>{children}</>;
}OAuth Providers
Google OAuth is configured in lib/auth.ts under socialProviders. To enable it, add the credentials to your environment:
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secretThen trigger the OAuth flow from a client component. The GoogleSignInButton component at components/auth/google-signin-button.tsx handles this:
import { signIn } from "@/lib/auth-client";
await signIn.social({ provider: "google", callbackURL: "/dashboard" });The callbackURL parameter controls where the user lands after a successful OAuth sign-in.
To add more providers (GitHub, Discord, etc.), add them to the socialProviders object in lib/auth.ts and supply the corresponding environment variables. Refer to the Better Auth social providers docs for the full list.
Role-Based Access Control
Better Auth's admin plugin adds a role field to the user record. Two roles are used out of the box: user (the default) and admin.
Assigning the Admin Role
Promote a user to admin using the admin client, which requires an existing admin session:
import { authClient } from "@/lib/auth-client";
await authClient.admin.setRole({ userId: "user-id", role: "admin" });For the very first admin, update the database directly:
UPDATE "user" SET role = 'admin' WHERE email = 'you@example.com';Checking the Role
Server-side, read the role from the session:
const session = await getSession();
const isAdmin = session?.user.role === "admin";Client-side, read it from useSession:
const { data: session } = useSession();
const isAdmin = session?.user.role === "admin";Auth Pages
All authentication pages live under app/auth/ and share a split-panel layout from app/auth/layout.tsx. The left panel contains a centered card with the form; the right panel (visible on large screens) shows a decorative gradient.
| Route | Component | Purpose |
|---|---|---|
/auth/sign-in | sign-in-form.tsx | Email/password sign-in + Google OAuth |
/auth/register | register-form.tsx | Account registration with name, email, password + Google OAuth |
/auth/forgot-password | forgot-password-form.tsx | Request a password reset email |
/auth/reset-password | reset-password-form.tsx | Set a new password (from email link) |
/auth/resend-verification | resend-verification-form.tsx | Resend email verification link |
/auth/error | error-content.tsx | Displays auth error messages |
The error page maps Better Auth error codes (INVALID_EMAIL_OR_PASSWORD, USER_ALREADY_EXISTS, PASSWORD_COMPROMISED, TOO_MANY_REQUESTS, banned) to user-readable messages. It is routed to automatically by the onAPIError.errorURL setting in lib/auth.ts.
Email Verification and Password Reset
Verification and reset emails are sent via the sendEmail function from lib/email.ts. The relevant hooks are configured in lib/auth.ts:
emailVerification.sendVerificationEmail: called on sign-up (sendOnSignUp: true)emailAndPassword.sendResetPassword: called when a user requests a password resetuser.changeEmail.sendChangeEmailVerification: called when a user changes their email (sent to the current email address for approval)user.deleteUser.sendDeleteAccountVerification: called when a user requests account deletion
All of these send HTML emails using the shared template system in lib/email.ts.
Important: autoSignInAfterVerification is set to false. After clicking the email verification link, users are redirected to the sign-in page, not signed in automatically. The register form sets callbackURL to /auth/sign-in?email=<encoded-email> so the email field is pre-filled after verification.
If a user needs to resend their verification email, they can visit /auth/resend-verification.
reCAPTCHA Integration
The sign-in, register, and forgot-password forms are protected by reCAPTCHA v3. Each form wraps its content in a ReCaptchaProvider and calls executeRecaptcha before submitting. The token is validated server-side via the validateRecaptcha server action in app/actions/recaptcha.ts.
The register form also calls isDisposableEmail(values.email) (a server action) before submitting and blocks registration if the email is from a known disposable provider. See the Email page for details on the disposable email blocklist.
Validation Schemas
All auth forms use Zod schemas from lib/validations/auth.ts with React Hook Form via zodResolver. These schemas handle client-side validation; Better Auth handles server-side validation independently:
| Schema | Used By | Key Rules |
|---|---|---|
registerSchema | Register form | Name (1–32 chars), email (max 254), password (8–128 chars) |
loginSchema | Sign-in form | Email, password (required) |
forgotPasswordSchema | Forgot password form | |
emailVerificationSchema | Resend verification form | |
resetPasswordSchema | Reset password form | Password + confirm (must match) |
changePasswordSchema | Change password form | Current password + new + confirm (must match) |
tokenVerificationSchema | Token verification | Token + email (required) |
emailChangeVerificationSchema | Email change verification | Token + email (required) |
Environment Variables
The following environment variables are required for authentication:
BETTER_AUTH_URL=http://localhost:3000 # Server-side base URL
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 # Client-side base URL
BETTER_AUTH_SECRET=your-secret # Auth secret (generate with: pnpm dlx @better-auth/cli secret)
GOOGLE_CLIENT_ID=your-client-id # Google OAuth client ID
GOOGLE_CLIENT_SECRET=your-client-secret # Google OAuth client secretBETTER_AUTH_URL is used server-side in lib/auth.ts. NEXT_PUBLIC_BETTER_AUTH_URL is used client-side in lib/auth-client.ts. Set both to your application's base URL.
API Route
The Better Auth API is mounted at /api/auth/[...all]/route.ts. All auth requests (sign-in, sign-out, OAuth callbacks, email verification links, Stripe webhooks) are handled here by Better Auth.
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);