Better Auth
Pre-configured authentication with ORM adapter integration, API routes, and shared auth package for monorepos.
Requirements: Database (PostgreSQL, MySQL, or SQLite) + ORM (Drizzle or Prisma). SQLite is supported with Drizzle only.
What create-faster adds
Files added:
src/
├── lib/auth/
│ ├── create-auth.ts # createAuth(cookiePlugin?) factory, Auth type
│ ├── auth.ts # The app's instance, built with its framework cookie plugin
│ ├── auth-client.ts # Client auth helpers
│ └── session-cache.ts # Per-request session cache, only with TanStack Start
└── app/api/auth/[...all]/
└── route.ts # Auth API handler
# Turborepo only (each app also gets its own src/lib/auth/auth.ts instance):
packages/auth/
├── src/
│ ├── auth.ts # createAuth() factory, no framework cookie plugin
│ ├── auth-client.ts
│ ├── session-cache.ts # Only with TanStack Start
│ └── types.ts
├── package.json # @repo/auth
└── tsconfig.jsonEnvironment variables:
BETTER_AUTH_SECRET- Auth secret key (scoped to auth package + app)BETTER_AUTH_URL- Auth URL (scoped to app)
create-auth.ts (packages/auth/src/auth.ts in a turborepo)
Server-side auth configuration with ORM adapter. Shown here with Drizzle + PostgreSQL:
import { db, userAccountTable, userSessionTable, userTable, userVerificationTable } from '@/lib/db';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { type BetterAuthPlugin, betterAuth } from 'better-auth';
const HOUR = 60 * 60;
const DAY = 24 * HOUR;
export function createAuth(cookiePlugin?: BetterAuthPlugin) {
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
usePlural: false,
schema: {
user: userTable,
account: userAccountTable,
session: userSessionTable,
verification: userVerificationTable,
},
}),
plugins: [...(cookiePlugin ? [cookiePlugin] : [])],
emailAndPassword: {
enabled: true,
minPasswordLength: 6,
revokeSessionsOnPasswordReset: true,
},
user: {
modelName: 'user',
fields: {
email: 'email',
emailVerified: 'emailVerified',
image: 'avatarUrl',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
},
session: {
modelName: 'session',
expiresIn: 15 * DAY,
},
account: { modelName: 'account' },
verification: { modelName: 'verification' },
});
}
export type Auth = ReturnType<typeof createAuth>;The factory knows no framework. It appends the cookie plugin after its own plugins and, without one, disables session refresh (a refresh through an instance that cannot forward cookies would extend the session in the database but not in the browser).
auth.ts
The app's instance, built with its framework's cookie plugin (apps/<app>/src/lib/auth/auth.ts in a turborepo):
import { nextCookies } from 'better-auth/next-js';
import { createAuth } from './create-auth';
export const auth = createAuth(nextCookies());Adapts automatically based on ORM and database selection:
- Drizzle: Uses
drizzleAdapterwithpg,mysql, orsqliteprovider - Prisma: Uses
prismaAdapterwithpostgresqlormysqlprovider
auth-client.ts
Client-side auth helpers with type inference:
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import type { Auth } from './create-auth';
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL || process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
plugins: [inferAdditionalFields<Auth>()],
});route.ts
Next.js API route handler:
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth/auth';
export const { GET, POST } = toNextJsHandler(auth.handler);Turborepo mode:
Creates @repo/auth package depending on @repo/db for database access. The package exports a framework-agnostic factory, createAuth(cookiePlugin?) (createAuth(db, cookiePlugin?) on Cloudflare D1), that appends the cookie plugin after its own plugins. Each app builds its own instance with its framework's cookie plugin and imports it from @/lib/auth/auth:
import { nextCookies } from 'better-auth/next-js';
import { createAuth } from '@repo/auth/auth';
export const auth = createAuth(nextCookies());A TanStack Start app uses tanstackStartCookies() the same way, so a Next.js app and a Start app can share one auth package. The tRPC package reads sessions with its own cookie-less createAuth(), which never refreshes: tRPC reads do not extend the session, and it must not call endpoints that set cookies. A single-repo Next.js app's tRPC context reads through the app's instance instead, so its reads extend the session; a single-repo TanStack Start app's tRPC context reads through its own cookie-less createAuth(), sessionReader in src/trpc/session-reader.ts; on D1 the context builds a cookie-less createAuth(db) per request.
TanStack Start
On TanStack Start the module generates the handler, the cookie plugin, the client and a getSession server function instead of the Next.js route. No protected layout is generated: guard routes in beforeLoad or loader.
src/
├── lib/auth/
│ ├── create-auth.ts # createAuth(cookiePlugin?) factory
│ ├── auth.ts # Instance built with tanstackStartCookies()
│ ├── auth-client.ts # Client auth helpers
│ ├── session-cache.ts # Per-request session cache
│ └── session.ts # getSession server function
└── routes/api/auth/
└── $.ts # Auth API handler (GET and POST)In turborepo mode session-cache.ts lives in @repo/auth (@repo/auth/session-cache) so an API package can import it; the route, getSession and the auth instance (with tanstackStartCookies()) stay in the app.
import { createFileRoute } from '@tanstack/react-router';
import { auth } from '@/lib/auth/auth';
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
});getRequestSession(headers, load) memoizes the session promise in a WeakMap<Headers, Promise<Session | null>>, keyed on request.headers (the same Headers object for the whole request), so loaders and server functions of one SSR request share a single lookup. getSession reads through it, and so does the tRPC context. session-cache.ts is plain TypeScript typed from the Auth type exported by the factory; it is generated when an app uses TanStack Start.
On Cloudflare with D1, auth is built once at module scope next to db in src/lib/server.ts with createAuth(db, tanstackStartCookies()). PostgreSQL and MySQL through Hyperdrive stay disabled with better-auth, like on Next.js.
Production rate limiting keys on the x-forwarded-for header (cf-connecting-ip on Cloudflare). next start fills it with the connection address when a request has none. A Start app served by Nitro does the same through src/lib/nitro/client-ip.ts, a Nitro plugin that vite.config.ts registers: a request that reaches the server directly is rate limited per client instead of sharing one bucket, and a header set by a proxy is left untouched. Not generated on Cloudflare.

