Next Starter Logo

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 hooks
  • lib/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:

  • sendResetPassword lives inside emailAndPassword and sends the password reset email via lib/email.ts
  • emailVerification.sendOnSignUp is true: a verification email is sent automatically on registration
  • emailVerification.autoSignInAfterVerification is false: after clicking the verification link, users are redirected to the sign-in page rather than being signed in automatically
  • user.changeEmail and user.deleteUser are both enabled, each with their own email verification flow
  • user.deleteUser.beforeDelete checks for active subscriptions (blocks deletion if any exist), cleans up related database records, and deletes avatar files from R2
  • admin() receives a custom bannedUserMessage shown to banned users
  • onAPIError.errorURL routes auth errors to /auth/error, where error codes are mapped to user-readable messages
  • nextCookies() 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-secret

Then 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.

RouteComponentPurpose
/auth/sign-insign-in-form.tsxEmail/password sign-in + Google OAuth
/auth/registerregister-form.tsxAccount registration with name, email, password + Google OAuth
/auth/forgot-passwordforgot-password-form.tsxRequest a password reset email
/auth/reset-passwordreset-password-form.tsxSet a new password (from email link)
/auth/resend-verificationresend-verification-form.tsxResend email verification link
/auth/errorerror-content.tsxDisplays 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 reset
  • user.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:

SchemaUsed ByKey Rules
registerSchemaRegister formName (1–32 chars), email (max 254), password (8–128 chars)
loginSchemaSign-in formEmail, password (required)
forgotPasswordSchemaForgot password formEmail
emailVerificationSchemaResend verification formEmail
resetPasswordSchemaReset password formPassword + confirm (must match)
changePasswordSchemaChange password formCurrent password + new + confirm (must match)
tokenVerificationSchemaToken verificationToken + email (required)
emailChangeVerificationSchemaEmail change verificationToken + 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 secret

BETTER_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);

On this page