# Next.js Auth

You are working in a Next.js 16 App Router project that authenticates with Better Auth 1.7 and TypeScript. Write code for the versions in `package.json`: Better Auth changes quickly, and much of what you remember (NextAuth, `middleware.ts`, the old CLI, passkeys in the core package) no longer applies.

## Before you write code

- Read `package.json` for `better-auth`, `next` and the database layer (Prisma, Drizzle or a `pg` pool). Check every import against `node_modules/better-auth/package.json` `exports` before you use it.
- Find the existing server instance (`lib/auth.ts` or `auth.ts`) and client (`lib/auth-client.ts`) and extend them. There is one of each per app.
- Option names and defaults live in `node_modules/@better-auth/core/dist/types/init-options.d.mts`; each plugin's options sit in `node_modules/better-auth/dist/plugins/<name>/`.
- For session reads under Cache Components, read `node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md`.

## The server instance

```ts
// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "@/lib/db";

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  emailAndPassword: { enabled: true, requireEmailVerification: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
  plugins: [nextCookies()], // keep nextCookies last
});
```

- Adapters: `prismaAdapter(prisma, { provider: "postgresql" })` from `better-auth/adapters/prisma`, `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle`, or pass a `pg` `Pool` straight to `database` for the built-in Kysely adapter. With an ORM adapter, `betterAuth` from `better-auth/minimal` leaves Kysely out of the bundle.
- `nextCookies()` from `better-auth/next-js` lets `auth.api` calls in Server Functions set cookies. It must be the last plugin in the array.
- Secrets come from the environment: `BETTER_AUTH_SECRET` (32+ random bytes, `openssl rand -base64 32`; production throws without one) and `BETTER_AUTH_URL` (the app's origin). Rotate with `secrets: [{ version, value }]` or `BETTER_AUTH_SECRETS`, never by swapping the secret in place.
- Extend the user with `user.additionalFields` (`{ type, required, defaultValue, input: false }`). Set `input: false` on anything a user must not write at sign-up, such as a role or plan.
- Run side effects in `databaseHooks` (`user.create.after`, `session.create.before`) rather than wrapping the endpoints.
- `emailAndPassword` hashes with scrypt by default. Wire `sendResetPassword({ user, url, token })` (with `revokeSessionsOnPasswordReset: true`) and `emailVerification.sendVerificationEmail({ user, url, token })`. Send the email in the background (`after()` from `next/server`) rather than awaiting it, so response time does not reveal whether an account exists.

## Route handler and client

```ts
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

```ts
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "@/lib/auth";

export const authClient = createAuthClient({
  plugins: [inferAdditionalFields<typeof auth>()],
});
```

- The catch-all path must match `basePath` (default `/api/auth`). Social callbacks land on `/api/auth/callback/<provider>`: register exactly that URL with each provider.
- Client code takes the server's type with `import type`, never `lib/auth.ts` itself.
- Add each server plugin's client counterpart from `better-auth/client/plugins` (`organizationClient`, `twoFactorClient`, `magicLinkClient`, `adminClient`).
- Client calls return `{ data, error }` and do not throw: `authClient.signIn.email({ email, password, callbackURL })`, `authClient.signIn.social({ provider: "github", callbackURL })`, `authClient.signUp.email(...)`, `authClient.signOut()`. Check `error` and show `error.message`.
- `authClient.useSession()` returns `{ data, isPending, error, refetch }`. Use it for UI state in Client Components, not to protect anything.

## Reading the session on the server

```ts
// lib/dal.ts
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export const getSession = cache(async () =>
  auth.api.getSession({ headers: await headers() })
);

export async function requireUser() {
  const session = await getSession();
  if (!session) redirect("/sign-in");
  return session.user;
}
```

- `auth.api.getSession({ headers: await headers() })` returns `{ session, user }` or `null`. Wrap it in React `cache()` so a layout and a page share one read.
- Authorize in the data layer and in every Server Function and Route Handler: call `requireUser()` (or check the role or organization) next to the query or mutation. A check in a layout does not protect the page or the action beneath it.
- Server Functions sign in with `auth.api.signInEmail({ body: { email, password }, headers: await headers() })`; `nextCookies()` writes the cookie. Errors throw `APIError` (from `better-auth/api`): catch it and return the message as a value, and call `redirect()` after the `try`.
- Pass the client only what it needs (`{ id, name, image }`), never the session token or the whole row.
- Use `auth.$Infer.Session` for the session type instead of hand-writing one.
- With `cacheComponents`, the session is request data: render what reads it inside `<Suspense>`, never call `getSession` inside a plain `"use cache"` function, and pass `user.id` into cached functions as an argument.
- With `session.cookieCache` enabled, a session can outlive its revocation for `maxAge`. Before a sensitive change (email, password, billing, deleting data), read with `query: { disableCookieCache: true }`.

## proxy.ts: an optimistic check only

```ts
// proxy.ts
import { type NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export function proxy(request: NextRequest) {
  if (!getSessionCookie(request)) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
```

- `getSessionCookie` only sees whether a cookie exists; it does not validate it. Use it to redirect signed-out visitors early, then validate in the data layer as above.
- No database reads in `proxy.ts`: it runs on every matched request, prefetches included. Next.js 16 names the file `proxy.ts` and the function `proxy`.

## Schema and migrations

- Plugins add tables and columns. After adding or changing a plugin or `additionalFields`, regenerate: `npx auth generate` writes the Prisma or Drizzle schema, then apply it with that ORM's migrations (`prisma migrate dev`, `drizzle-kit`).
- `npx auth migrate` applies changes directly and only works with the built-in Kysely adapter.
- Change auth models through the config and regenerate, not by hand.

## Plugins

Import server plugins from `better-auth/plugins` and add each one's client plugin.

- `organization()` for B2B: organizations, members, invitations (`sendInvitationEmail`), an active organization on the session (`session.activeOrganizationId`), optional `teams`. Define roles with `createAccessControl` from `better-auth/plugins/access`, start from `defaultStatements`, `ownerAc`, `adminAc` and `memberAc` (`better-auth/plugins/organization/access`), and pass the same `ac` and `roles` to `organizationClient`. On the server, check with `auth.api.hasPermission({ headers, body: { permissions: { project: ["create"] } } })`; never trust a role sent by the client.
- `twoFactor({ issuer })` adds TOTP, email or SMS OTP and backup codes. On the client, `twoFactorClient({ onTwoFactorRedirect })` sends the user to your verification page when sign-in needs a second factor.
- Passkeys are not in `better-auth` 1.7: they ship as `@better-auth/passkey` with their own client plugin. Read its README in `node_modules` after installing; do not import a passkey plugin from `better-auth/plugins`.
- `magicLink({ sendMagicLink({ email, url }) })`, signed in with `authClient.signIn.magicLink({ email, callbackURL })`. Rate limit the send per address.
- `admin()` adds `role`, banning and impersonation (`adminRoles`, `impersonationSessionDuration`). Only an admin session may call its endpoints; check that server side too.

## Accounts, origins and rate limits

- Account linking is on by default: a social sign-in joins an existing user with the same email when the provider verified it (or is in `trustedProviders`) and the local user is verified. List only providers that verify emails in `trustedProviders`, set `disableImplicitLinking: true` to require explicit linking, and never enable `allowDifferentEmails` without a reason in writing.
- Let signed-in users connect providers with `authClient.linkSocial({ provider, callbackURL })` and handle the error when that account belongs to another user.
- `trustedOrigins` lists every origin allowed to call auth or appear in `callbackURL` (your production domain, preview and local origins; wildcards like `https://*.example.com` work). The base URL is trusted already. Never trust `*`.
- Rate limiting is on in production only and kept in memory by default, with stricter built-in rules for sign-in, sign-up and `/request-password-reset`. On serverless or several instances, set `rateLimit.storage` to `"database"` or `"secondary-storage"` so limits hold everywhere; adjust paths with `customRules` (`"/sign-in/email": { window: 60, max: 5 }`).
- Behind a proxy, set `advanced.ipAddress.ipAddressHeaders` to the header your host sets, so limits key on the real client IP.

## Do not

- Do not use NextAuth or Auth.js patterns (`getServerSession`, `[...nextauth]`, `useSession` from `next-auth/react`) in a Better Auth project.
- Do not write `middleware.ts`, query the database in `proxy.ts`, or treat the proxy cookie check as authorization.
- Do not read the session in a Client Component to protect data, store tokens in `localStorage`, or return the session token to the browser.
- Do not put `nextCookies()` anywhere but last, mount the handler at a path other than `basePath`, or create a second `betterAuth` instance per request.
- Do not run `npx auth migrate` against a Prisma or Drizzle project, or skip regenerating the schema after adding a plugin.
- Do not copy old guides' `@better-auth/cli` commands or import passkeys from `better-auth/plugins`.

## Before you finish

- Run the type checker and linter. Client methods for each plugin should type-check; if they do not, a client plugin is missing.
- Regenerate the schema and create a migration if you touched plugins or fields; read the migration before applying it.
- Test sign up, sign in, sign out and a protected Server Function while signed out (it must refuse), and confirm a social callback URL matches the provider's settings.
- List the environment variables you added (`BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, provider IDs and secrets) for the reviewer.
