Claude Code with better-auth: Type-Safe Auth in TypeScript
Why better-auth without CLAUDE.md leaks session state across the boundary
better-auth is framework-agnostic, which means it makes no assumptions about where your server lives relative to your client. That flexibility is a strength in production. In development with Claude Code, it is a trap. Claude knows that authentication libraries involve a server config, a client config, and a session object. What Claude does not know, without explicit instructions, is how better-auth draws the boundary between those three things.
The most common result is session state leakage across the server/client boundary. Claude generates a useSession call on a server component, passes the auth object directly into a client component as a prop, or calls auth.api.getSession() without forwarding the request headers, returning null for every authenticated user. Claude also defaults to putting the client baseURL as a relative path like /api/auth instead of a full absolute URL, which breaks in any environment where the client and server are on different origins. And when a database adapter is required, Claude will sometimes skip it entirely, generating an in-memory auth config that has no persistence between restarts.
None of these mistakes are obscure. They all appear in the first thirty minutes of a new better-auth integration. A CLAUDE.md that defines the boundary, the adapter requirement, the baseURL convention, and the server-only patterns eliminates every one of them before the first line is generated.
This guide covers the CLAUDE.md configuration that anchors Claude Code to better-auth's actual model: a server-only auth.ts config with a required database adapter, a client-side authClient.ts with the correct absolute baseURL, typed sessions via $Infer, and the plugin import paths Claude needs to generate 2FA, organization, and magic-link features correctly. If you are pairing better-auth with Drizzle ORM, the Claude Code with Drizzle guide covers the adapter setup in depth. If you are migrating from NextAuth, Claude Code with NextAuth covers what changes and what carries over.
The better-auth CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For a better-auth integration it needs to declare: the version and adapter, the lib/auth.ts server config location, the lib/auth-client.ts client location, the API route mount path, the session access pattern for server components, the TypeScript inference pattern for typed sessions, the plugin import paths, and the hard rules that block the patterns Claude generates most often without guidance.
# Authentication rules (better-auth)
## Stack
- better-auth 1.x, TypeScript 5.x strict
- Next.js 15 App Router (async cookies API)
- Drizzle ORM with PostgreSQL adapter (or Prisma, see adapter section)
- Tailwind 4.x
## File locations
- lib/auth.ts Server-only auth config. NEVER import in client components.
- lib/auth-client.ts Client-only auth client. NEVER import in server components.
- app/api/auth/[...all]/route.ts API route handler
- lib/db/schema/auth.ts Drizzle schema tables for better-auth
## Server config (lib/auth.ts)
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
}),
emailAndPassword: {
enabled: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
});
export type Session = typeof auth.$Infer.Session;
## Client config (lib/auth-client.ts)
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!, // MUST be full absolute URL
});
export const { signIn, signOut, useSession } = authClient;
## API route (app/api/auth/[...all]/route.ts)
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth';
export const { GET, POST } = toNextJsHandler(auth);
## Session access patterns
### Server component (CORRECT)
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
const session = await auth.api.getSession({
headers: await headers(), // MUST pass headers, returns null without them
});
### Client component (CORRECT)
'use client';
import { useSession } from '@/lib/auth-client';
const { data: session, isPending } = useSession();
// session.user, session.session available when not null
### WRONG patterns (NEVER generate these)
- Calling auth.api.getSession() without passing headers
- Importing lib/auth.ts in a client component
- Importing lib/auth-client.ts in a server component
- Passing session data from server to client component as props (use useSession instead)
- Using baseURL: '/api/auth' (relative path), must be full URL
## TypeScript session typing
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
Use Session and User types throughout, never use any for session data.
## Database adapter rules
- ALWAYS include a database adapter, better-auth has no in-memory persistence
- Drizzle: drizzleAdapter(db, { provider: 'pg' | 'mysql' | 'sqlite' })
- Prisma: prismaAdapter(prisma, { provider: 'postgresql' | 'mysql' | 'sqlite' })
- Run 'npx better-auth generate' to generate schema, then migrate
- Required tables: user, session, account, verification
## Plugin import paths
import { twoFactor } from 'better-auth/plugins';
import { organization } from 'better-auth/plugins';
import { magicLink } from 'better-auth/plugins';
import { otp } from 'better-auth/plugins';
Add to server config:
plugins: [twoFactor(), organization(), magicLink({ ... })]
## Hard rules
- NEVER generate auth.api.getSession() without passing headers
- NEVER use baseURL: '/api/auth', always full absolute URL from env var
- NEVER import auth.ts in client components
- NEVER import auth-client.ts in server components
- NEVER skip the database adapter, no adapter = no persistence
- ALWAYS generate the schema tables before running migrations
- Next.js 15: cookies() is async, await headers() not headers()
Three rules here eliminate the failures Claude generates most often without them.
The headers-required rule for auth.api.getSession() is the single most impactful entry. better-auth reads the session cookie from the incoming request headers. On the server, those headers must be explicitly forwarded. Without them, getSession returns null regardless of whether the user is authenticated. Claude generates auth.api.getSession() without arguments because that is the correct pattern for many auth libraries. The explicit rule changes that default.
The absolute-baseURL rule matters because better-auth's client uses the baseURL to construct fetch calls to your API. A relative path like /api/auth works when client and server share an origin, but breaks during local development with different ports, in preview deployments where the URL changes, or in any setup where the client is served separately. An environment variable holding the full URL works everywhere. Claude will use a relative path by default. The rule prevents it.
The no-in-memory rule about the database adapter is the one that produces the most confusing bugs. Without an adapter, better-auth falls back to in-memory storage. Sessions appear to work during a request and vanish on the next one. The symptom looks like broken session logic, but the cause is missing persistence. Requiring an adapter in CLAUDE.md means Claude never generates an auth.ts without one.
Installation and database schema setup
Install better-auth and the adapter for your ORM:
npm install better-auth
# For Drizzle
npm install drizzle-orm @auth/drizzle-adapter
# For Prisma
npm install @prisma/client
better-auth requires four tables: user, session, account, and verification. Rather than writing these by hand, use the CLI to generate the schema for your ORM:
npx better-auth generate
This outputs a schema file. For Drizzle with PostgreSQL it produces a file you add to lib/db/schema/auth.ts. Add it to your schema index and run your migration:
npx drizzle-kit generate
npx drizzle-kit migrate
For Prisma, npx better-auth generate outputs Prisma model blocks. Paste them into schema.prisma and run:
npx prisma migrate dev --name add-better-auth
Add this to CLAUDE.md so Claude generates the correct schema workflow:
## Schema generation
- Run 'npx better-auth generate' first to get schema output for your ORM
- Drizzle: add generated file to lib/db/schema/auth.ts, then run drizzle-kit generate + migrate
- Prisma: paste generated models into schema.prisma, then run prisma migrate dev
- NEVER hand-write the four auth tables (user, session, account, verification)
- Re-run 'npx better-auth generate' when adding plugins (plugins add new tables)
The re-run note matters because plugins extend the schema. Adding the organization plugin requires an organization table and a member table. Adding twoFactor adds a twoFactor table. Claude will not know to re-run the generator after adding a plugin unless the rule is explicit.
The auth.ts server config in full
Here is the complete server config for a project with email+password, GitHub OAuth, and the organization plugin:
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { organization, twoFactor } from 'better-auth/plugins';
import { db } from './db';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // set true in production
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
updateAge: 60 * 60 * 24, // refresh when older than 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // cache session for 5 minutes
},
},
plugins: [
organization(),
twoFactor(),
],
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
The $Infer.Session type is generated from the config at build time. It reflects which plugins are active, which social providers are configured, and what fields exist on the session. Using typeof auth.$Infer.Session instead of a hand-written interface means the type stays in sync when you add a plugin. Claude will generate a hand-written interface by default, which drifts. The CLAUDE.md rule locks it to the inferred type.
For Prisma instead of Drizzle, the adapter import and instantiation change but nothing else does:
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { prisma } from './db';
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
// rest identical
});
The Claude Code with Drizzle guide covers the drizzleAdapter setup in detail, including the db instance configuration. The Claude Code with Prisma guide covers the equivalent for Prisma, including the singleton pattern that prevents connection exhaustion in Next.js dev mode.
Social OAuth providers
Social providers in better-auth follow the same pattern regardless of which provider you add. Each one requires a clientId and clientSecret from the provider's developer console. The OAuth callback URL is always {baseURL}/api/auth/callback/{provider}, and better-auth registers it automatically based on your mount path.
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
},
On the client side, triggering a social sign-in is one call:
await signIn.social({
provider: 'github',
callbackURL: '/dashboard',
});
Add to CLAUDE.md:
## Social provider OAuth flow
- Callback URL auto-registered: {NEXT_PUBLIC_APP_URL}/api/auth/callback/{provider}
- Register this URL in each provider's OAuth app settings
- Client trigger: signIn.social({ provider: 'github', callbackURL: '/dashboard' })
- NEVER hardcode callbackURL as relative path, use env var or absolute URL
- After sign-in, better-auth creates or links an account row in the account table
The callback URL registration note is important. Claude will generate the correct client code but will not know to tell the developer to register {baseURL}/api/auth/callback/github in GitHub's OAuth app settings. Without that registration, the OAuth flow fails with a redirect_uri_mismatch error. The note in CLAUDE.md means Claude surfaces this in the instructions it generates alongside the code.
Email and password flow
Email and password authentication requires emailAndPassword: { enabled: true } in the server config. The client-side calls are signUp.email and signIn.email:
// Sign up
const { data, error } = await authClient.signUp.email({
email: 'user@example.com',
password: 'secure-password',
name: 'Jane Smith',
callbackURL: '/dashboard',
});
// Sign in
const { data, error } = await authClient.signIn.email({
email: 'user@example.com',
password: 'secure-password',
callbackURL: '/dashboard',
rememberMe: true,
});
Both return { data, error }. data contains the user and session on success. error contains a message and code on failure. Claude will sometimes generate a try/catch around these calls instead of checking the error return, which swallows better-auth's structured error codes. Add the return-checking pattern to CLAUDE.md:
## Email/password error handling
- signUp.email and signIn.email return { data, error }, check error, do not wrap in try/catch
- error.code values: USER_ALREADY_EXISTS, INVALID_EMAIL_OR_PASSWORD, etc.
- Display error.message to the user directly, better-auth messages are user-friendly
- NEVER use try/catch on authClient calls, the error is in the return value, not thrown
For password resets and email verification, better-auth handles the token generation and email sending via the configured email provider. Adding a sendResetPassword hook to the server config gives you control over the email content:
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
text: `Reset link: ${url}`,
});
},
},
Session management: cookies vs JWT
better-auth uses database-backed sessions by default. A session row is written to the session table on sign-in and read on every authenticated request. This gives you revocation (delete the row to invalidate the session), auditability, and the ability to list active sessions per user. The tradeoff is a database read on every request.
The cookieCache option in the session config reduces that cost. When enabled, better-auth stores a short-lived encrypted copy of the session in the cookie. Requests within the cache window skip the database read and use the cookie data directly. Set maxAge to 5 minutes for most apps:
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
better-auth does not natively issue JWTs for session management in the way that NextAuth's jwt strategy does. If your architecture requires stateless JWT sessions, use the bearer plugin, which adds token-based auth alongside the cookie session. For most Next.js apps, the cookie session with cookieCache is the correct choice.
Add to CLAUDE.md:
## Session strategy
- Default: database-backed cookie session (session table row + HttpOnly cookie)
- cookieCache reduces DB reads, enable for production, maxAge: 5 * 60 (5 min)
- NEVER use JWT as the primary session strategy unless using the bearer plugin explicitly
- Session revocation: delete the session row or call authClient.signOut()
- Next.js 15: cookies() is async, use 'await headers()' not 'headers()' in server components
The Next.js 15 note is critical. Next.js 15 made cookies() and headers() async. Code generated against Next.js 14 examples calls headers() synchronously. In a Next.js 15 project that fails at runtime. The rule prevents Claude from generating the wrong version.
Client-side useSession hook
The useSession hook is better-auth's primary session access mechanism for client components. It returns { data, isPending, error }. The data object contains { user, session } when the user is authenticated and null when they are not.
'use client';
import { useSession } from '@/lib/auth-client';
export function UserNav() {
const { data: session, isPending } = useSession();
if (isPending) return <Skeleton />;
if (!session) return <SignInButton />;
return (
<div>
<span>{session.user.name}</span>
<span>{session.user.email}</span>
</div>
);
}
isPending is true during the initial session fetch. Rendering a skeleton or null during this phase prevents a flash of unauthenticated UI on page load. Claude will sometimes omit the isPending check, generating a component that flashes the signed-out state for every page load even when the user is authenticated.
For sign-out:
await signOut({
fetchOptions: {
onSuccess: () => {
router.push('/');
},
},
});
Add to CLAUDE.md:
## useSession hook
- Returns { data: { user, session } | null, isPending, error }
- ALWAYS handle isPending, render skeleton/null while session loads
- data is null when unauthenticated, non-null when authenticated
- data.user has: id, name, email, image, emailVerified, createdAt
- data.session has: id, userId, expiresAt, token
- NEVER access session.user without checking that session is non-null first
Plugins: 2FA, organizations, and magic links
better-auth plugins extend both the server config and the client. Each plugin is imported from better-auth/plugins on the server. Some plugins also require a client-side import for their additional methods.
Two-factor authentication
// lib/auth.ts
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
// ...
plugins: [twoFactor()],
});
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
plugins: [twoFactorClient()],
});
The two-factor plugin adds authClient.twoFactor.enable(), authClient.twoFactor.disable(), and authClient.twoFactor.verifyTotp() to the client. Without the twoFactorClient() in the client config, those methods are undefined at runtime. Claude will add the server plugin but miss the client plugin. The rule in CLAUDE.md prevents that.
Organizations
import { organization } from 'better-auth/plugins';
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
}),
],
The organization plugin adds organization, member, and invitation tables to the schema. Run npx better-auth generate again after adding it to get the updated schema.
Magic links
import { magicLink } from 'better-auth/plugins';
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({ to: email, subject: 'Sign in link', text: url });
},
}),
],
Add to CLAUDE.md:
## Plugin rules
- Server plugins: import from 'better-auth/plugins'
- Client plugins: import from 'better-auth/client/plugins'
- ALWAYS add client plugin when a server plugin has a corresponding client plugin
(twoFactor requires twoFactorClient, organization requires organizationClient)
- Re-run 'npx better-auth generate' after adding any plugin that adds tables
- Plugins that require email sending (magicLink, emailOtp) need sendMagicLink/sendOtp callback
Common Claude mistakes and how to prevent them
These are the mistakes Claude makes most often on better-auth projects without CLAUDE.md. Each one has a root cause and a CLAUDE.md rule that prevents it.
Missing database adapter. Claude generates betterAuth({}) with no database key when it does not have explicit instructions. The result is an in-memory auth store that loses all sessions on server restart. The CLAUDE.md rule that declares a database adapter as required prevents this.
Wrong baseURL on the client. Claude generates createAuthClient({ baseURL: '/api/auth' }). The correct value is the full app URL: process.env.NEXT_PUBLIC_APP_URL. The rule declaring the env var pattern prevents it.
Missing headers on server-side getSession. Claude generates await auth.api.getSession() with no arguments in server components. Without { headers: await headers() }, the call returns null. The explicit pattern in CLAUDE.md prevents the omission.
Mixing better-auth and NextAuth helpers. On projects that migrated from NextAuth, Claude sometimes reaches for getServerSession from next-auth/next or signIn from next-auth/react. If NextAuth is still installed, these calls compile and return undefined or throw at runtime. The CLAUDE.md hard rule listing forbidden imports prevents this.
Synchronous cookies() in Next.js 15. Claude trained on Next.js 14 examples calls headers() synchronously. In Next.js 15, this throws a runtime error because headers() returns a Promise. The explicit await headers() example in CLAUDE.md corrects this default.
Skipping the client plugin for server plugins. When adding twoFactor() to the server, Claude does not automatically add twoFactorClient() to the client config. The result is missing methods on the client at runtime. The plugin pairing rule in CLAUDE.md prevents the omission.
For projects that use Clerk instead of better-auth, Claude Code with Clerk covers the equivalent CLAUDE.md patterns for that library. For projects on Next.js 15 specifically, Claude Code with Next.js covers the async APIs and App Router conventions that interact with auth patterns.
Building auth that Claude gets right by default
The better-auth CLAUDE.md in this guide produces an auth integration where the server config always has a database adapter, the client always has an absolute baseURL, getSession always passes headers, session types are inferred not hand-written, plugins are paired correctly on both server and client, and Next.js 15's async headers API is used throughout.
The underlying pattern is consistent across all better-auth integrations with Claude Code. Claude's defaults are reasonable for generic TypeScript, but better-auth has specific conventions around the server/client boundary, the adapter requirement, and the header-passing model that Claude cannot infer from the library name alone. A CLAUDE.md that makes those conventions explicit converts Claude from a source of subtle auth bugs into a reliable code generator for a security-critical layer of your application.
Claudify includes a better-auth CLAUDE.md template with the full server config, client config, API route, session type patterns, plugin pairing rules, and Next.js 15 compatibility notes shown in this guide, ready to drop into any TypeScript project.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify