Skip to content
Chapter 52Lesson 1

Wiring the auth instance

Lay the Better Auth foundation in Next.js, the server instance, catch-all route, and browser client every later auth feature builds on.

Last chapter you built the mental model without a real library: a session is an opaque handle in a hardened __Host- cookie, pointing at a row the server owns and can delete in one statement. The library that issues that cookie, stores that row, and answers “who is this request from?” is Better Auth, and this lesson is where its API appears.

Every later auth feature imports the same handful of files, so none of them should re-decide how auth is wired. This lesson lays that foundation: install the package, write the server instance, mount one route, create the browser client, set two env vars, and prove the wiring responds. The auth instance you end with does almost nothing yet, by design.

Better Auth gives you two objects. The server instance is configured once and holds every decision: which database, which secret, which plugins. The browser client is a typed wrapper your React components call to trigger auth actions like signing in. They never talk directly; a single HTTP endpoint bridges them, a catch-all route mounted at /api/auth/[...all]. So there are exactly two ways to reach the instance, decided by where your code runs.

Server code Server Components, Server Actions, route handlers, proxy.ts
Browser code Client Components (forms, session UI)
app/api/auth/[...all]/route.ts the catch-all endpoint
lib/auth.ts the server auth instance
Two ways into one instance. Server code calls it in-process; browser code reaches it over HTTP through the catch-all route. Both end at the same auth object.

Server code runs in the same Node process as the instance, so it calls the object directly with auth.api.getSession(...): no network hop, because the instance is right there in memory. Browser code can’t do that. A React component in someone’s Chrome tab has no access to your server’s memory, so it POSTs to the catch-all route, which forwards the request to the same instance and returns the answer. Same destination, two transports, and the route sits only on the browser path.

Past the one-line install, the wiring lives in four files, one you’ll edit and three you’ll create:

src/env.ts

Two validated env entries, BETTER_AUTH_SECRET and BETTER_AUTH_URL, added to the existing server schema.

src/lib/auth.ts

The server auth instance: the single source of truth for server-side auth config.

src/app/api/auth/[...all]/route.ts

The catch-all route handler. One file exposes the entire auth HTTP API.

src/lib/auth-client.ts

The browser authClient, what React Client Components call to trigger auth actions.

This lesson turns on no auth feature: no email and password, no Google sign-in, no session lifetime tuning. Those bolt onto this foundation later, email and password next chapter, cookie hardening two lessons from now. You’re building only the surface area everything else imports. That’s why the smoke test at the end returns null, which is the correct answer: there’s no way to sign in yet, so there’s no session to find.

The install is one line.

Terminal window
pnpm add better-auth

Better Auth ships as one package with everything inside it, reached through sub-paths. There are no companion packages to add: no separate React adapter, no database integration, no @types/better-auth. That single install gives you all of this:

The server function

betterAuth(...) from better-auth, the function that builds your instance.

Database adapters

Including the Drizzle adapter you’ll wire next lesson, under better-auth/adapters/drizzle.

Next.js helpers

nextCookies and toNextJsHandler from better-auth/next-js.

The React client

createAuthClient from better-auth/react.

Every file in this lesson pulls from one of those sub-paths. Here is the whole import surface in one place:

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies, toNextJsHandler } from 'better-auth/next-js';
import { createAuthClient } from 'better-auth/react';

Four imports, four files: the entire Better Auth API you touch this lesson.

The instance needs two values from the environment before it can construct. Both go into src/env.ts, the validated layer from when you set up the database, where every variable passes through Zod before the app will boot.

The first is BETTER_AUTH_SECRET, the key Better Auth uses for all encryption, signing, and hashing: signing the cookie cache , binding the OAuth state and PKCE verifiers from last chapter, and more. It must be a high-entropy random string of at least 32 characters. Generate one with:

Terminal window
openssl rand -base64 32

The Better Auth installation docs also offer a one-click generator in the browser. Either way, treat the output like any secret: it goes in .env, never in source control.

The second is BETTER_AUTH_URL, the public origin of your app: http://localhost:3000 in development, https://app.example.com in production. Better Auth uses it to compute redirect URLs and scope cookies, and you’ll pass the same value to the instance as its baseURL.

Both are server variables that must never reach the browser bundle, so they go in the server block of env.ts, alongside the DATABASE_URL and RESEND_API_KEY already there.

export const env = createEnv({
server: {
DATABASE_URL: z.url(),
RESEND_API_KEY: z.string().min(1),
},
// client, runtimeEnv, etc. omitted
});

The two entries from earlier chapters, each validated at build time so a missing or malformed value stops the boot.

The load-bearing decision here is the placement in server, not the Zod refinements. The server/client split is a structural guard: the validation layer throws if a server variable is read from client code, so the secret can’t leak into a browser bundle through this path. Never prefix the secret with NEXT_PUBLIC_ to make it available somewhere, since that prefix is exactly what ships a value to the browser.

Give each environment its own BETTER_AUTH_SECRET, set through Vercel’s per-environment variables, so a leaked staging secret can’t forge a production session.

This is the center of the lesson. src/lib/auth.ts is the single source of truth for everything server-side about auth: every other file references it, never the other way around. It’s a small file today and grows over the next few lessons, so read it as a starting point.

The first line is the same server-only guard you put on the database client:

import 'server-only';

It’s a compile-time guard. If any Client Component imports this module, directly or through a shared helper, the build fails instead of silently bundling your server auth library, its database access, and its secret-reading env into the browser. This code must never ship to the client, so an accidental import is made impossible rather than merely discouraged.

Here’s the whole file, then a walk through each decision.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

The server-only guard, as the first line. A side-effecting import with no binding, just the protection: an accidental client import becomes a build error rather than a leaked bundle.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

The three Better Auth imports, each from a sub-path of the single package: betterAuth builds the instance, drizzleAdapter connects it to Postgres, and nextCookies is the plugin discussed below. These are the import lines you previewed during the install.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

Existing infrastructure, reused. db is the Drizzle client from when you set up the database, and env is the validated env you just extended. The instance composes what’s already there; it doesn’t invent its own database connection or read raw process.env.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

database is how Better Auth persists into your Postgres. It’s named here so the import resolves and the instance is complete; how the adapter maps storage, and the four tables it needs, is next lesson’s subject.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

nextCookies() is the one line you cannot omit. See the explanation and callout below: this is the most common way to break Better Auth in Next.js, and it stays hidden until you build sign-in next chapter.

import 'server-only';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/db';
import { env } from '@/env';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
plugins: [nextCookies()],
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
});

secret and baseURL, pulled from validated env. Better Auth would auto-read these from the matching env-var names, but passing them explicitly routes through your validation layer and keeps the config legible.

1 / 1

Most of the file is plain composition: it hands the database client and the env you already have to betterAuth. The database: drizzleAdapter(db, { provider: 'pg' }) line is next lesson’s entire subject, how Better Auth’s adapter turns its storage calls into Drizzle queries and which four tables it needs, so it’s named here today only so the instance is complete. provider: 'pg' tells the adapter you’re on Postgres.

One line deserves more:

plugins: [nextCookies()],

Omit it and you hit the most confusing bug in the library, which is why it’s worth understanding the moment it goes in. In Next.js, when auth logic runs inside a Server Action and sets a cookie, the Set-Cookie header it produces doesn’t automatically attach to the action’s response. The cookie is created server-side, then quietly dropped on the way out. nextCookies() is the plugin that fixes this: it intercepts those headers and attaches them through Next’s cookies() helper. Nothing errors when it’s missing, which is what makes it costly: sign-up returns a clean 200 and a session row exists, but no cookie reaches the browser, so the user is never signed in.

It’s a plugin rather than a default because Better Auth is framework-agnostic; the Next-specific cookie behavior is opt-in, which is what a plugin is. And nextCookies() must be the last entry in the plugins array, so it runs after every other plugin and captures every Set-Cookie header they emit. The array holds nothing else today, but when the organizations plugin and others arrive later they’ll sit before it, and the wrong order reintroduces the exact bug you just guarded against.

Two things this file is growing toward. It will eventually also export a SESSION_COOKIE_PREFIX constant so proxy.ts and other cookie readers can match the configured prefix without hardcoding it twice; it’s absent because the __Host- prefix itself gets configured two lessons from now, when you harden the cookie. And the config object is small now, but its full BetterAuthOptions type is large. It fills in on a schedule:

OptionWhen it’s added
database, secret, baseURL, pluginsThis lesson
session, advanced (cookie tuning)Two lessons from now
emailAndPassword, socialProviders, emailVerification, accountNext chapter
trustedOriginsNamed only; defaults to your baseURL origin

trustedOrigins is Better Auth’s CSRF allowlist. It defaults to your baseURL origin, correct for a same-origin app, and you widen it only when a client on a different origin appears, such as a mobile app or browser extension. Leave it defaulted.

This is the endpoint from the diagram, the bridge the browser client crosses to reach the instance. The whole file is two meaningful lines.

src/app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);

Start with the folder name, [...all]. That’s a Next.js catch-all segment, which you met when you learned the App Router. It means this one file matches every path beneath /api/auth/, not a single route but all of them:

Path the browser hitsWhat it does
POST /api/auth/sign-up/emailCreate an account with email and password
POST /api/auth/sign-in/emailSign in with email and password
POST /api/auth/sign-outEnd the current session
GET /api/auth/sessionRead the current session
GET /api/auth/callback/googleLand an OAuth provider’s redirect

…and dozens more. Better Auth’s internal router reads the rest of the path, the part [...all] captured, and dispatches to the right handler. So one file exposes the entire auth API. You write it once and never touch it again, no matter how many auth features you turn on.

toNextJsHandler(auth) takes your instance and returns an object holding GET and POST handler functions. The export const { GET, POST } = ... destructures those out and re-exports them, which is the shape Next.js route handlers expect: one named export per HTTP method. This is the canonical Better Auth body, so write it verbatim.

The reflex to build: never hand-write individual route files for sign-in, sign-up, or OAuth callbacks. The catch-all is the contract between your browser client and the framework about where auth requests land. The authClient you’re about to write POSTs to paths under this mount, and those POSTs resolve only because the catch-all is here to catch them.

The fourth file is the other end of that HTTP path, the typed wrapper your React components call. It’s smaller still.

src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient();

createAuthClient() builds a client whose methods mirror the auth API: authClient.signIn.email(...), authClient.signUp.email(...), authClient.signOut(), authClient.useSession(), and the rest of the browser-callable surface, which you’ll meet properly next chapter. Each method is a typed wrapper over an HTTP call to the catch-all route, so calling authClient.signIn.email(...) in a component is really a POST to /api/auth/sign-in/email. The client is the type-safe face on the raw HTTP from the table above.

Two omissions in that bare call are deliberate.

The first is baseURL. The client defaults it to the current origin, the domain the browser is already on, which is correct for a single-origin Next.js app: the auth endpoints live at /api/auth/* on the same domain as your pages. Omit baseURL for same-origin, and set it only when the auth server lives on a different origin than the browser app. Passing it when you don’t need to adds a configuration surface that can drift out of sync.

The second is any guard: no import 'server-only', no import 'client-only'. This is a plain module that Client Components import freely. Contrast lib/auth.ts, which leads with server-only. That asymmetry is the boundary made concrete: the server instance is fenced off from the client, while the browser client is meant for client code. Two files with opposite rules, because they live on opposite sides of the wire.

That brings us to the rule that ties the whole chapter together.

You now have both faces of Better Auth: auth on the server and authClient in the browser. The most common mistake with this library is calling the wrong one from the wrong place, so let’s make the rule sharp and build the reflex.

  • On the server, meaning Server Components, Server Actions, route handlers, and proxy.ts, call auth.api.*. This runs in-process, with no HTTP.
  • In the browser, meaning Client Components, call authClient.*. This goes over HTTP to the catch-all route.

The names mirror each other because both sides reach the same endpoints; they just travel differently. Here is the same operation, sign-up, from each side.

// Inside a Server Action or route handler
await auth.api.signUpEmail({ body: { email, password, name } });

Server code holds the instance, so it calls auth.api.signUpEmail directly, in-process.

Both reach POST /api/auth/sign-up/email. Treat these snippets as shape, not a working sign-up; the point here is only which object each side reaches for.

The boundary is hard, not a suggestion, because crossing it breaks in a concrete way each direction.

Import auth into a Client Component and you pull the entire server library, its database access and secret-reading env, into the browser bundle. The server-only line on lib/auth.ts turns that attempt into a build error before it can ship. It could never work anyway: the server path is an in-process call, and a browser tab has no access to your server’s process. The HTTP route exists precisely because that direct path is impossible from the browser.

Import authClient into a Server Component and you make the inverse mistake. The client’s whole job is to turn calls into HTTP requests, but on the server the instance is already in memory, reachable directly. Going over HTTP to your own server is both wrong and slower than the direct path sitting right there.

Each item is a place that needs to talk to Better Auth. Sort it by where the code runs — that's the only thing that decides which object it reaches for. Drag each item into the bucket it belongs to, then press Check.

Call `auth.api` Runs on the server — holds the instance directly
Call `authClient` Runs in the browser — goes over HTTP
A Server Action reading the current user before a mutation
A sign-in form’s submit handler in a 'use client' component
proxy.ts checking whether a session exists
A header avatar in a Client Component showing who’s signed in
A “Sign out” button’s onClick in a 'use client' component
A route handler returning the current session as JSON
A Server Component layout that hides an admin link when signed out

Four files and two env entries are now enough to answer a request, so let’s confirm the wiring responds.

Better Auth serves GET /api/auth/session through your catch-all route. Hit it with no session cookie and it answers null: no cookie, no session. That null is the signal you want.

  1. Make sure both env vars are set in your .env: BETTER_AUTH_SECRET (from openssl rand -base64 32) and BETTER_AUTH_URL (http://localhost:3000 in dev).

  2. Start the dev server:

    Terminal window
    pnpm dev
  3. Hit the session endpoint. Open http://localhost:3000/api/auth/session in the browser, or from a terminal:

    Terminal window
    curl http://localhost:3000/api/auth/session
  4. You should see the response body:

    null

To return that null, two things had to hold: the catch-all route was mounted and dispatching, and the instance constructed without throwing, which means both env vars resolved and validated. The null itself is correct, since there’s no sign-in surface yet and so no session to find.

This only exercises the read path on an anonymous request, which touches no database tables, so it works before any tables exist. Sign-up won’t work yet. The next lesson generates and migrates the four tables Better Auth needs (user, session, account, verification) so a session has somewhere to be written.