# Better Auth with Next.js 16: proxy.ts, Prisma 7 and Server Actions (Tested Setup)

**Better Auth** is an open-source (MIT) TypeScript authentication library that runs inside your own app and stores users and sessions in your own database. With Next.js 16 you mount one route handler, call `auth.api.getSession()` in server components and `proxy.ts`, and add the `nextCookies()` plugin so server actions can set cookies.

I built a complete Next.js 16.4.2 app with Better Auth 1.7.10, Prisma 7.10.1 and PostgreSQL 17: email and password sign-up and sign-in through server actions, a protected dashboard, a session check in `proxy.ts` and sign-out. Then I tested it with curl and headless Chrome, and counted every database query per page load. The setup works as documented, with two exceptions: one setting that does nothing on Prisma 7 unless you turn on a preview feature, and a React 19 form reset that wipes the email field.

## TL;DR

Install `better-auth`, create `lib/auth.ts` with `prismaAdapter(prisma)` and `nextCookies()`, mount `toNextJsHandler(auth)` at `app/api/auth/[...all]/route.ts`, generate the schema with `npx auth@latest generate` and migrate with Prisma. Check the session in every protected page, not only in `proxy.ts`. A full proxy check plus a page check cost 4 database queries per load in my test, and the cookie cache cut that to 0.

| Piece | File | What it does |
| --- | --- | --- |
| Server config | `src/lib/auth.ts` | `betterAuth({ database, emailAndPassword, plugins })` |
| API route | `src/app/api/auth/[...all]/route.ts` | `toNextJsHandler(auth)` serves `/api/auth/*` |
| Client | `src/lib/auth-client.ts` | `createAuthClient()` from `better-auth/react` |
| Route guard | `src/proxy.ts` | Redirects signed-out users (optimistic) |
| Real check | each protected `page.tsx` | `auth.api.getSession({ headers: await headers() })` |
| Schema | `prisma/schema.prisma` | `user`, `session`, `account`, `verification` |

Versions tested on October 17, 2026: `better-auth` 1.7.10 (released October 15; 1.8.0-beta.1 is on the `beta` tag), Next.js 16.4.2, React 19.3.1, Prisma 7.10.1, Node.js 24.22.0.

## What is Better Auth?

Better Auth is a framework-agnostic authentication library for TypeScript. It handles email and password, social sign-in, sessions, and plugins for 2FA, organizations and passkeys, all inside your own server. Users, sessions and accounts live in your database through an adapter for Prisma, Drizzle, Kysely or a direct connection. There is no hosted service to call.

It is MIT licensed, had about 30,000 GitHub stars on [its repository](https://github.com/better-auth/better-auth) when I checked, and has shipped stable 1.x releases since 1.0.0 in November 2024. Its own security docs say passwords are hashed with `scrypt` by default.

## How do I set up Better Auth in Next.js 16?

Install `better-auth`, add `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` to `.env`, create a `betterAuth()` instance with a database adapter, mount the handler at `app/api/auth/[...all]/route.ts`, then generate and migrate the schema. With Prisma 7 that takes six files and two CLI commands. Each step below is the exact code I ran.

![Better Auth request flow in Next.js 16: browser to proxy.ts optimistic check, page.tsx getSession, Better Auth API route and Prisma to PostgreSQL user, session, account and verification tables](https://res.cloudinary.com/wasicloudinary/image/upload/v1792250133/fabwaseem/blog/better-auth-nextjs/diagram-flow.jpg)

### Step 1: Install packages and set the secret

```bash title="Terminal"
npm i better-auth @prisma/client @prisma/adapter-pg
npm i -D prisma dotenv
npx prisma init --datasource-provider postgresql --output ../src/generated/prisma
```

```bash title=".env"
DATABASE_URL="postgresql://demo:demo@127.0.0.1:5432/badb"
BETTER_AUTH_SECRET="at-least-32-random-characters"   # openssl rand -base64 32
BETTER_AUTH_URL="http://localhost:3000"
```

`better-auth` 1.7.10 already depends on `@better-auth/prisma-adapter`, so `import { prismaAdapter } from "better-auth/adapters/prisma"` works without installing the adapter separately.

Two things surprised me with Prisma 7.10.1. `prisma init` wrote `prisma7.config.ts`, not `prisma.config.ts`. It also installed Prisma agent skills into `.agents/`, `.claude/` and `.windsurf/`, so review that before you commit. Also, if your shell already exports `DATABASE_URL`, dotenv will not override it. My first `migrate dev` targeted the wrong database and asked to reset it. Read the target in the prompt before you answer yes.

### Step 2: Create the Prisma client and the auth instance

```ts title="src/lib/prisma.ts"
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }) });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
```

```ts title="src/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 "./prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  emailAndPassword: { enabled: true },
  plugins: [nextCookies()], // keep this last
});

export type Session = typeof auth.$Infer.Session;
```

### Step 3: Mount the API route and create the client

```ts title="src/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 title="src/lib/auth-client.ts"
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();
```

All Better Auth endpoints now live under `/api/auth`, for example `POST /api/auth/sign-up/email` and `GET /api/auth/get-session`.

### Step 4: Generate the schema and migrate

```bash title="Terminal"
npx prisma generate
npx auth@latest generate --config src/lib/auth.ts --yes
npx prisma migrate dev --name add-better-auth
```

Run `prisma generate` first. With no generated client, `auth generate` could not load `src/lib/auth.ts` and hung for three minutes until I killed it. Once the client existed, it finished in seconds and wrote four models, `User`, `Session`, `Account` and `Verification`, mapped to the tables `user`, `session`, `account` and `verification`. The CLI only writes the schema. As the [Better Auth Prisma adapter docs](https://www.better-auth.com/docs/adapters/prisma) say, migrations are left to Prisma.

Better Auth 1.7 also checks the schema at startup. Before the migration ran, it logged `Prisma schema mismatch` and listed the missing tables. That is a useful early warning in CI.

## How do I sign in with a server action?

Call `auth.api.signInEmail()` or `auth.api.signUpEmail()` inside a `"use server"` function, and keep the `nextCookies()` plugin in your config. Without it the server action succeeds but sets no cookie. In my test the user stayed signed out and was redirected back to the sign-in page. With it, the session cookie was set and the redirect to `/dashboard` worked.

```ts title="src/app/actions.ts"
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { APIError } from "better-auth/api";
import { auth } from "@/lib/auth";

export type SignInState = { error: string; email: string } | null;

export async function signIn(_: SignInState, form: FormData): Promise<SignInState> {
  const email = String(form.get("email"));
  try {
    await auth.api.signInEmail({ body: { email, password: String(form.get("password")) } });
  } catch (e) {
    // Return the email: React 19 resets the form after an action.
    if (e instanceof APIError) return { error: e.message, email };
    throw e;
  }
  redirect("/dashboard");
}

export async function signOut() {
  await auth.api.signOut({ headers: await headers() });
  redirect("/sign-in");
}
```

```tsx title="src/app/sign-in/page.tsx (sign-in form)"
"use client";

import { useActionState } from "react";
import { signIn } from "../actions";

export default function SignInPage() {
  const [state, action, pending] = useActionState(signIn, null);
  return (
    <form action={action}>
      <input name="email" type="email" defaultValue={state?.email} key={state?.email} required />
      <input name="password" type="password" required />
      <button disabled={pending}>Sign in</button>
      {state?.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
```

Why return the email? React 19 resets a form after its action finishes. In my Chrome test, a wrong password showed "Invalid email or password" and the email field was empty. After returning the email from the action and using it as `defaultValue`, the field kept its value. See [useActionState](https://react.dev/reference/react/useActionState) for the pattern. Keep `redirect()` outside the `try` block, because it works by throwing.

## How do I protect routes with proxy.ts?

Next.js 16 renamed `middleware.ts` to `proxy.ts`, and it runs on Node.js, so `auth.api.getSession()` works there. Use the proxy only to redirect signed-out users early. Then check the session again in every protected page or server action, because the proxy can be skipped by a matcher mistake and it is not a security boundary.

```ts title="src/proxy.ts"
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

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

```tsx title="src/app/dashboard/page.tsx"
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { signOut } from "../actions";

export default async function DashboardPage() {
  // The proxy only redirects. This check is the one that protects the page.
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect("/sign-in");

  return (
    <main>
      <h1>Welcome, {session.user.name}</h1>
      <form action={signOut}>
        <button>Sign out</button>
      </form>
    </main>
  );
}
```

For a proxy with no database call, the [Better Auth Next.js docs](https://www.better-auth.com/docs/integrations/next) offer `getSessionCookie(request)` from `better-auth/cookies`, and label it "THIS IS NOT SECURE!" I tested why. A request with a made-up cookie, `better-auth.session_token=forged.value`, passed the cookie-only proxy. The page's own `getSession()` call then redirected it to `/sign-in`. With the full check in `proxy.ts`, the forged cookie was redirected at the proxy.

Next.js documents the rename and the codemod (`npx @next/codemod@canary middleware-to-proxy .`) in its [proxy.ts reference](https://nextjs.org/docs/app/api-reference/file-conventions/proxy).

## What did my tests show?

Everything in the core flow worked on the first production build (`next build` then `next start`). I tested with curl against the HTTP API and with headless Chrome 155 through the real forms and server actions:

| Test | Result |
| --- | --- |
| Visit `/dashboard` signed out | 307 to `/sign-in` |
| Sign up through the form (server action) | Redirect to `/dashboard`, "Welcome, UI Tester" |
| Same, without `nextCookies()` | No cookie set, user stays signed out |
| Session cookie | `better-auth.session_token`, `HttpOnly`, `SameSite=Lax`, `Max-Age=604800` (7 days) |
| Wrong password | 401 `INVALID_EMAIL_OR_PASSWORD` |
| Sign-in with `Origin: https://evil.example` | 403 `INVALID_ORIGIN` |
| Forged cookie, full proxy check | 307 to `/sign-in` |
| Forged cookie, cookie-only proxy | Passes proxy, page redirects to `/sign-in` |
| Sign out, then visit `/dashboard` | 307 to `/sign-in` |

Two notes. A curl request with no `Origin` header at all was accepted. That matches the [Better Auth security docs](https://www.better-auth.com/docs/reference/security), which allow requests that carry neither an Origin nor Fetch Metadata headers so that server-to-server and native clients keep working. Also, my cookie had no `Secure` flag over `http://localhost`. The [cookies docs](https://www.better-auth.com/docs/concepts/cookies) say cookies are secure in production mode, and `advanced.useSecureCookies: true` forces it.

## How many database queries does Better Auth make per request?

Each `getSession()` call made 2 queries on Prisma 7: one for the session by token and one for the user. A full check in `proxy.ts` plus a check in the page therefore cost 4 queries per page load. The cookie cache brought that to 0, and a cookie-only proxy to 2. I counted with PostgreSQL statement logging, over two warm dashboard loads per setup.

![Database queries per protected page load with Better Auth and Prisma 7: full proxy and page check 4, joins true without relationJoins 4, with relationJoins 2, cookie-only proxy 2, cookie cache 0](https://res.cloudinary.com/wasicloudinary/image/upload/v1792250133/fabwaseem/blog/better-auth-nextjs/diagram-queries.jpg)

| Setup (proxy + page check) | Queries per load | Median load time (local DB) |
| --- | --- | --- |
| Full `getSession()` in proxy and page | 4 | 7.3 ms |
| + `advanced.database.joins: true` | **4** (no change) | not measured |
| + `joins: true` and Prisma `relationJoins` preview | 2 | 6.8 ms |
| + `session.cookieCache` (5 min) | 0 | 5.9 ms |
| Cookie-only proxy, full check in page | 2 | 6.0 ms |

Times are the median of 300 requests from 2 runs against PostgreSQL on the same machine, so each query cost well under a millisecond. With a database in another region, every query is a network round trip, so the query count matters more than these timings suggest.

### Why doesn't `joins: true` reduce queries on Prisma 7?

Because Prisma 7 still runs `include` as separate queries unless you enable its `relationJoins` preview. With `joins: true` alone, the PostgreSQL log still showed `SELECT ... FROM "session"` followed by `SELECT ... FROM "user" WHERE "id" IN ($1)`. After adding `previewFeatures = ["relationJoins"]` and running `prisma generate`, getSession became one `LEFT JOIN LATERAL` query.

```prisma title="prisma/schema.prisma"
generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  previewFeatures = ["relationJoins"]
}
```

```ts title="src/lib/auth.ts"
export const auth = betterAuth({
  // ...
  advanced: { database: { joins: true } },
});
```

The Better Auth docs promise "2x to 3x performance improvements" from joins, depending on database latency, without mentioning the Prisma preview flag. Prisma's [relation queries docs](https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries) list `relationJoins` as Preview. One caution: in my [Drizzle vs Prisma benchmark](https://www.fabwaseem.com/blog/drizzle-vs-prisma), `relationJoins` made paginated `include` queries with a deep `OFFSET` much slower. The flag applies to the whole client, so measure your other queries after enabling it.

### Should I enable the cookie cache?

Enable it if your sessions rarely change and you can accept a short revocation delay. With `cookieCache: { enabled: true, maxAge: 5 * 60 }`, Better Auth stores the session in a signed `better-auth.session_data` cookie and skips the database. The [session management docs](https://www.better-auth.com/docs/concepts/session-management) warn that a revoked session "may remain active on other devices until the cookie cache expires."

```ts title="src/lib/auth.ts"
export const auth = betterAuth({
  // ...
  session: {
    cookieCache: { enabled: true, maxAge: 5 * 60 }, // seconds
  },
});
```

One catch I hit. With a cookie-only proxy, the cache cookie never got written, because the only `getSession()` call ran in a server component and React Server Components cannot set cookies. The query count stayed at 2. Once `session_data` was set by a route handler (`/api/auth/get-session`) or by the full check in `proxy.ts`, page loads dropped to 0 queries. The [Better Auth Next.js docs](https://www.better-auth.com/docs/integrations/next) warn about this: "As RSCs cannot set cookies, the cookie cache will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers."

## Better Auth vs Clerk: which should I use?

Choose Better Auth when you want auth inside your own app and database, no per-user vendor pricing and full control of the schema and UI. Choose Clerk when you want a hosted service with prebuilt sign-in components, a user dashboard and managed infrastructure. You trade control and data ownership for less code to run.


| | Better Auth | Clerk |
| --- | --- | --- |
| What it is | Library inside your app | Hosted authentication service |
| Where users live | Your database (Prisma, Drizzle, Kysely, SQL) | Clerk's infrastructure |
| License | MIT, open source | Commercial SaaS |
| UI | You build it (or use community components) | Prebuilt components |
| Self-hosting | Yes, runs wherever Next.js runs | No |
| You operate | Database, secrets, email delivery, upgrades | Integration only |

Pricing and limits change, so check [Clerk's docs](https://clerk.com/docs) for current plans. If you self-host Next.js, Better Auth fits that setup. My [Next.js AI starter kit](https://www.fabwaseem.com/blog/nextjs-ai-starter-kit) uses Better Auth 1.7.10 with Drizzle for that reason.

## Can I use Better Auth with Prisma 8 or Drizzle?

Drizzle yes, Prisma 8 not yet. Better Auth 1.7.10's peer dependencies accept `drizzle-orm` 0.45.2 or newer, including the 1.0 release candidates, and `@prisma/client` 5, 6 or 7. Prisma 8 replaces `@prisma/client` with a new runtime package, so the Prisma adapter does not cover it yet.

If you are starting a new project, my [Prisma 8 guide](https://www.fabwaseem.com/blog/prisma-8) explains why to pin Prisma 7 for now. The Drizzle adapter is `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle`.

## Common Better Auth mistakes in Next.js

- **Relying on the proxy alone.** Check the session in every protected page, route handler and server action.
- **Forgetting `nextCookies()`.** Sign-in through a server action silently sets no cookie. Keep it last in `plugins`.
- **Putting `redirect()` inside `try`.** `redirect` throws, so your `catch` swallows it.
- **Running `auth generate` before `prisma generate`.** It cannot import a client that does not exist yet.
- **Expecting `joins: true` to work on Prisma 7 by itself.** It needs the `relationJoins` preview.
- **A cookie-only proxy plus the cookie cache.** RSCs cannot write the cache cookie, so it stays empty.
- **Hard-coding `BETTER_AUTH_SECRET`.** Generate 32+ random bytes and keep it in your deployment's environment, not in git.

For the wider question of when to use server actions and when to use route handlers, see my [server actions vs route handlers guide](https://www.fabwaseem.com/blog/server-actions-vs-route-handlers).

## Key takeaways

- Better Auth 1.7.10 works with Next.js 16.4 and Prisma 7: one route handler, an auth instance with prismaAdapter, and nextCookies() for server actions.
- Generate the Prisma client before running npx auth generate, then migrate with Prisma; the Better Auth CLI writes the schema but does not migrate.
- proxy.ts can run a full getSession() on Node.js, but always check the session again in each page; a forged cookie passed the cookie-only proxy in my test.
- A full proxy check plus a page check cost 4 database queries per load on Prisma 7; the cookie cache cut it to 0.
- advanced.database.joins only reduced queries after enabling Prisma's relationJoins preview.
- React 19 resets forms after an action, so return the email from a failed sign-in action to keep it in the field.
- Better Auth supports Drizzle 0.45 and 1.0 RC and Prisma 5 to 7, but not Prisma 8 yet.

---

*Originally published on [fabwaseem.com](https://www.fabwaseem.com/blog/better-auth-nextjs).*

