Next.js & React

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

A tested Better Auth 1.7 setup for Next.js 16.4 with Prisma 7 and PostgreSQL: route handler, server-action sign-in with nextCookies, proxy.ts checks, query counts per page load, the joins gotcha and Better Auth vs Clerk.

Better Auth with Next.js 16 cover: a src/proxy.ts panel calling auth.api.getSession, with chips for Better Auth 1.7, Next.js 16 with Prisma and 4 queries per load
On this page
  1. TL;DR
  2. What is Better Auth?
  3. How do I set up Better Auth in Next.js 16?
  4. Step 1: Install packages and set the secret
  5. Step 2: Create the Prisma client and the auth instance
  6. Step 3: Mount the API route and create the client
  7. Step 4: Generate the schema and migrate
  8. How do I sign in with a server action?
  9. How do I protect routes with proxy.ts?
  10. What did my tests show?
  11. How many database queries does Better Auth make per request?
  12. Why doesn't joins: true reduce queries on Prisma 7?
  13. Should I enable the cookie cache?
  14. Better Auth vs Clerk: which should I use?
  15. Can I use Better Auth with Prisma 8 or Drizzle?
  16. Common Better Auth mistakes in Next.js
  17. Key takeaways

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.

PieceFileWhat it does
Server configsrc/lib/auth.tsbetterAuth({ database, emailAndPassword, plugins })
API routesrc/app/api/auth/[...all]/route.tstoNextJsHandler(auth) serves /api/auth/*
Clientsrc/lib/auth-client.tscreateAuthClient() from better-auth/react
Route guardsrc/proxy.tsRedirects signed-out users (optimistic)
Real checkeach protected page.tsxauth.api.getSession({ headers: await headers() })
Schemaprisma/schema.prismauser, 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 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

Step 1: Install packages and set the secret#

bash
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
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
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
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
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
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
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 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
"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
"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 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
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
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 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.

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:

TestResult
Visit /dashboard signed out307 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 cookiebetter-auth.session_token, HttpOnly, SameSite=Lax, Max-Age=604800 (7 days)
Wrong password401 INVALID_EMAIL_OR_PASSWORD
Sign-in with Origin: https://evil.example403 INVALID_ORIGIN
Forged cookie, full proxy check307 to /sign-in
Forged cookie, cookie-only proxyPasses proxy, page redirects to /sign-in
Sign out, then visit /dashboard307 to /sign-in

Two notes. A curl request with no Origin header at all was accepted. That matches the Better Auth security docs, 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 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

Setup (proxy + page check)Queries per loadMedian load time (local DB)
Full getSession() in proxy and page47.3 ms
+ advanced.database.joins: true4 (no change)not measured
+ joins: true and Prisma relationJoins preview26.8 ms
+ session.cookieCache (5 min)05.9 ms
Cookie-only proxy, full check in page26.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
generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  previewFeatures = ["relationJoins"]
}
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 list relationJoins as Preview. One caution: in my Drizzle vs Prisma benchmark, 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.

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 warn that a revoked session "may remain active on other devices until the cookie cache expires."

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 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 AuthClerk
What it isLibrary inside your appHosted authentication service
Where users liveYour database (Prisma, Drizzle, Kysely, SQL)Clerk's infrastructure
LicenseMIT, open sourceCommercial SaaS
UIYou build it (or use community components)Prebuilt components
Self-hostingYes, runs wherever Next.js runsNo
You operateDatabase, secrets, email delivery, upgradesIntegration only

Pricing and limits change, so check Clerk's docs for current plans. If you self-host Next.js, Better Auth fits that setup. My Next.js 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 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.

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.

Questions