Scaffold Better Auth
Scaffolds a complete better-auth authentication system in a Bun monorepo with Drizzle ORM + PostgreSQL.
Arguments
$1 - auth package name (default: auth). Creates packages/$1/.
--app <name> - optional app name to wire integration into. Creates route handler, session functions, login UI in apps/<name>/.
Detect @{org} scope from root package.json name field.
Steps
Step 1 - Auth Package Setup
Create packages/<name>/ with the following files:
package.json
{
"name": "@<org>/auth",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./client": "./src/auth.client.ts"
},
"scripts": {
"fmt": "biome format --write ./src",
"lint": "biome lint --write ./src",
"check": "biome check ./src",
"typecheck": "tsc --noEmit",
"schema": "bunx @better-auth/cli generate --output ../db/src/schema/auth.schema.ts --yes"
},
"dependencies": {
"@better-auth/core": "^1.6.2",
"@better-auth/infra": "^0.2.0",
"@<org>/db": "workspace:*",
"better-auth": "^1.6.2",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.5",
"resend": "^6.10.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@<org>/biome-config": "workspace:*",
"@<org>/tsconfig": "workspace:*",
"drizzle-kit": "^0.31.0",
"typescript": "^6.0.2"
}
}
tsconfig.json - use the packages/auth variant from /dlc-scaffold-tsconfig (extends base, bundler module resolution, no declaration output).
biome.jsonc - use the minimal variant (Elysia / UI / DB / Auth) from /dlc-scaffold-biome.
src/index.ts - barrel re-exporting ./auth.config (prod factory + types), ./auth.test-config (test factory + types), and ./permissions (ac, Role, roles, statement). See references/auth-test-config.md for the exact export block.
auth.ts (root) - export createAuth() with empty config for CLI schema generation:
import { createAuth } from "./src/auth.config";
export const auth = createAuth({} as Parameters<typeof createAuth>[0]);
Step 2 - Server Config
See references/auth-server-config.md for the full template.
Key patterns:
CreateAuthOptions type with all env-driven params (db, siteUrl, secrets, API keys)
createAuthOptions() internal function returning BetterAuthOptions
- Drizzle adapter with
provider: "pg" and UUID generation
- RBAC via
admin({ ac, roles, defaultRole: "user", adminRoles: ["admin"] }) wired to the shared ac/roles from ./permissions (Step 3). Roles are lowercase user/admin; the plugin adds role/banned/banReason/banExpires columns
- Plugins:
tanstackStartCookies(), organization(), admin(), emailOTP() (6-digit, 600s, Resend delivery), sentinel(), dash() - sentinel/dash import from @better-auth/infra (server-side)
- Google OAuth with
prompt: "select_account" and accessType: "offline"
- Type exports:
Auth, AuthOptions, AuthSession, AuthUser, BetterAuth, BetterAuthOptions
Step 3 - Permissions (RBAC)
See references/auth-permissions.md for the full template.
Creates src/permissions.ts (and src/permissions.test.ts) - the access-control statements and roles map shared by the server admin() plugin and the client adminClient() so role checks resolve identically on both sides.
Key patterns:
statement = { ...defaultStatements } from better-auth/plugins/admin/access (resources user, session); extend with app resources as needed
ac = createAccessControl(statement); user role (empty) and admin role (...adminAc.statements)
- Exports
ac, roles, statement, and the Role type ("user" | "admin")
permissions.test.ts asserts admin grants (user:ban, user:delete, session:revoke) and user denials
Step 4 - Client Config
See references/auth-client-config.md for the full template.
Key patterns:
- Zod-validated env config for site URL (
VITE_<APP>_SITE_URL)
- Typed export
authClient: AuthClient<BetterAuthClientOptions>
- Plugins mirror the server:
organizationClient(), emailOTPClient(), tanstackStartCookies(), sentinelClient(), dashClient(), adminClient({ ac, roles })
- Infra client plugins import from
@better-auth/infra/client; adminClient consumes the same ac/roles from ./permissions
Step 5 - Test Config
See references/auth-test-config.md for the full template.
Creates src/auth.test-config.ts - a createTestAuth factory mirroring production for E2E/unit tests, with testUtils({ captureOTP: true }), a no-op sendVerificationOTP, and no OAuth/sentinel/dash. Only db, siteUrl, betterAuthSecret are required. Never import from production code paths.
Step 6 - Database Schema
See references/auth-schema.md for the full Drizzle schema reference.
The schema is auto-generated by bun run schema (output ../db/src/schema/auth.schema.ts) but provided as reference. Uses plain pgTable (default/public schema, not a dedicated pgSchema) with 7 tables: user, session, account, verification, organization, member, invitation. role is a plain text column (default user), not an enum. UUID PKs, cascading deletes, indexed FKs.
Step 7 - App Integration (only if --app provided)
See references/auth-app-integration.md for all templates.
Creates:
- Auth lib singleton (
apps/<app>/src/lib/auth/index.ts) - Zod env validation + lazy getAuth()
- Session functions (
apps/<app>/src/lib/auth/functions.ts) - getSession, ensureSession server functions
- Auth API (
apps/<app>/src/core/api/auth.api.ts) - sendOtp, verifyOtp (with asResponse: true cookie forwarding), signOut
- Route handler (
apps/<app>/src/_routes/api/auth/$.ts) - catch-all GET/POST delegating to getAuth().handler()
- Root route - add
getSession() in beforeLoad, return session in context
- Auth guard layout (
apps/<app>/src/_routes/_authed/_layout.tsx) - redirect to /login if no session
- Admin guard (
apps/<app>/src/_routes/_authed/admin/_layout.tsx) - redirect if role !== "admin"
Step 8 - Login UI (only if --app provided)
See references/auth-login-ui.md for all component templates.
Creates:
- Login page (
/login/index.tsx) - redirects if authenticated, renders LoginForm
- OTP page (
/login/code-confirm.tsx) - validates ?email= search param, renders OtpVerificationForm
- OAuth callback (
/auth/callback.tsx) - immediate redirect (better-auth handles OAuth at /api/auth/*)
- LoginForm - Google OAuth button + email input + OTP send using coss Card, Button, Input, Field
- OtpVerificationForm - 6-digit InputOTP with auto-submit, resend, back using coss Card, Button, InputOTP
- Role utilities -
UserRole (re-exports Role), getUserRole(), isAdmin(), useIsAdmin() hook (all check lowercase admin)
- ViewMode context - admin/user view switching for
admin users
Step 9 - Environment Variables
Create .env.example in project root:
| Variable |
Where |
Purpose |
DATABASE_URL |
server |
PostgreSQL connection string |
BETTER_AUTH_SECRET |
server |
Session signing secret |
RESEND_API_KEY |
server |
Email OTP delivery via Resend |
GOOGLE_CLIENT_ID |
server |
Google OAuth credentials |
GOOGLE_CLIENT_SECRET |
server |
Google OAuth credentials |
VITE_<APP>_SITE_URL |
both |
Base URL (e.g. http://localhost:3000) |
Step 10 - Verify
bun install
bun test packages/<name>/src/permissions.test.ts - RBAC roles pass
bun run typecheck - zero errors
bun run schema - generates Drizzle schema in db package
bun run db:generate && bun run db:migrate - creates auth tables in PostgreSQL
- If app integration: dev server starts,
/login renders, /api/auth/ok returns 200
Critical Gotchas
asResponse: true in verifyOtp is mandatory - without it, session cookies never reach the browser in SSR frameworks
- Dynamic import for Resend (
await import("resend")) keeps it out of client bundles
- Lazy singleton
getAuth() avoids module-level side effects that break SSR/prerendering
- UUID generation must use
advanced.database.generateId: "uuid" - Drizzle adapter defaults to cuid otherwise
- Cascading deletes on session/account FKs - always set
onDelete: "cascade" referencing user.id
- Cookie forwarding in
verifyOtp - must read set-cookie from response and call setResponseHeader("set-cookie", setCookie)
- Shared permissions -
auth.config.ts, auth.client.ts, and auth.test-config.ts must all import the same ac/roles from ./permissions. Defining roles in only one place desyncs server/client authorization
- Lowercase roles - role values are
user/admin (not USER/ADMIN); the role column is plain text, so a casing mismatch silently fails every admin check
@better-auth/infra import paths - server uses sentinel/dash from @better-auth/infra; client uses sentinelClient/dashClient from @better-auth/infra/client. Swapping them breaks the build
1---2name: dlc-scaffold-better-auth3description: Scaffold better-auth with Drizzle + PostgreSQL in a Bun monorepo. Email OTP, Google OAuth, admin() RBAC with shared permissions, organizations, TanStack Start integration, test config, and coss login UI. Use when setting up auth, adding authentication, or creating login flows.4license: UNLICENSED5metadata:6 source: https://skillmd.com/skills/delacournz/dlc-scaffold-better-auth7 author: chris@delacour.co.nz8 version: "0.2.0"9 category: auth10 tags: [auth, better-auth, drizzle, otp, oauth, rbac, organization]11---1213# Scaffold Better Auth1415Scaffolds a complete better-auth authentication system in a Bun monorepo with Drizzle ORM + PostgreSQL.1617## Arguments1819- `$1` - auth package name (default: `auth`). Creates `packages/$1/`.20- `--app <name>` - optional app name to wire integration into. Creates route handler, session functions, login UI in `apps/<name>/`.2122Detect `@{org}` scope from root `package.json` `name` field.2324## Steps2526### Step 1 - Auth Package Setup2728Create `packages/<name>/` with the following files:2930**`package.json`**31```json32{33 "name": "@<org>/auth",34 "version": "1.0.0",35 "type": "module",36 "main": "./src/index.ts",37 "exports": {38 ".": "./src/index.ts",39 "./client": "./src/auth.client.ts"40 },41 "scripts": {42 "fmt": "biome format --write ./src",43 "lint": "biome lint --write ./src",44 "check": "biome check ./src",45 "typecheck": "tsc --noEmit",46 "schema": "bunx @better-auth/cli generate --output ../db/src/schema/auth.schema.ts --yes"47 },48 "dependencies": {49 "@better-auth/core": "^1.6.2",50 "@better-auth/infra": "^0.2.0",51 "@<org>/db": "workspace:*",52 "better-auth": "^1.6.2",53 "drizzle-orm": "^0.45.2",54 "postgres": "^3.4.5",55 "resend": "^6.10.0",56 "zod": "^4.3.6"57 },58 "devDependencies": {59 "@<org>/biome-config": "workspace:*",60 "@<org>/tsconfig": "workspace:*",61 "drizzle-kit": "^0.31.0",62 "typescript": "^6.0.2"63 }64}65```6667**`tsconfig.json`** - use the `packages/auth` variant from `/dlc-scaffold-tsconfig` (extends base, bundler module resolution, no declaration output).6869**`biome.jsonc`** - use the minimal variant (Elysia / UI / DB / Auth) from `/dlc-scaffold-biome`.7071**`src/index.ts`** - barrel re-exporting `./auth.config` (prod factory + types), `./auth.test-config` (test factory + types), and `./permissions` (`ac`, `Role`, `roles`, `statement`). See `references/auth-test-config.md` for the exact export block.7273**`auth.ts`** (root) - export `createAuth()` with empty config for CLI schema generation:74```typescript75import { createAuth } from "./src/auth.config";76export const auth = createAuth({} as Parameters<typeof createAuth>[0]);77```7879### Step 2 - Server Config8081See `references/auth-server-config.md` for the full template.8283Key patterns:84- `CreateAuthOptions` type with all env-driven params (db, siteUrl, secrets, API keys)85- `createAuthOptions()` internal function returning `BetterAuthOptions`86- Drizzle adapter with `provider: "pg"` and UUID generation87- RBAC via `admin({ ac, roles, defaultRole: "user", adminRoles: ["admin"] })` wired to the shared `ac`/`roles` from `./permissions` (Step 3). Roles are lowercase `user`/`admin`; the plugin adds `role`/`banned`/`banReason`/`banExpires` columns88- Plugins: `tanstackStartCookies()`, `organization()`, `admin()`, `emailOTP()` (6-digit, 600s, Resend delivery), `sentinel()`, `dash()` - `sentinel`/`dash` import from `@better-auth/infra` (server-side)89- Google OAuth with `prompt: "select_account"` and `accessType: "offline"`90- Type exports: `Auth`, `AuthOptions`, `AuthSession`, `AuthUser`, `BetterAuth`, `BetterAuthOptions`9192### Step 3 - Permissions (RBAC)9394See `references/auth-permissions.md` for the full template.9596Creates `src/permissions.ts` (and `src/permissions.test.ts`) - the access-control statements and `roles` map shared by the server `admin()` plugin and the client `adminClient()` so role checks resolve identically on both sides.9798Key patterns:99- `statement = { ...defaultStatements }` from `better-auth/plugins/admin/access` (resources `user`, `session`); extend with app resources as needed100- `ac = createAccessControl(statement)`; `user` role (empty) and `admin` role (`...adminAc.statements`)101- Exports `ac`, `roles`, `statement`, and the `Role` type (`"user" | "admin"`)102- `permissions.test.ts` asserts admin grants (`user:ban`, `user:delete`, `session:revoke`) and user denials103104### Step 4 - Client Config105106See `references/auth-client-config.md` for the full template.107108Key patterns:109- Zod-validated env config for site URL (`VITE_<APP>_SITE_URL`)110- Typed export `authClient: AuthClient<BetterAuthClientOptions>`111- Plugins mirror the server: `organizationClient()`, `emailOTPClient()`, `tanstackStartCookies()`, `sentinelClient()`, `dashClient()`, `adminClient({ ac, roles })`112- Infra client plugins import from `@better-auth/infra/client`; `adminClient` consumes the same `ac`/`roles` from `./permissions`113114### Step 5 - Test Config115116See `references/auth-test-config.md` for the full template.117118Creates `src/auth.test-config.ts` - a `createTestAuth` factory mirroring production for E2E/unit tests, with `testUtils({ captureOTP: true })`, a no-op `sendVerificationOTP`, and no OAuth/`sentinel`/`dash`. Only `db`, `siteUrl`, `betterAuthSecret` are required. Never import from production code paths.119120### Step 6 - Database Schema121122See `references/auth-schema.md` for the full Drizzle schema reference.123124The schema is auto-generated by `bun run schema` (output `../db/src/schema/auth.schema.ts`) but provided as reference. Uses plain `pgTable` (default/public schema, not a dedicated `pgSchema`) with 7 tables: user, session, account, verification, organization, member, invitation. `role` is a plain `text` column (default `user`), not an enum. UUID PKs, cascading deletes, indexed FKs.125126### Step 7 - App Integration (only if `--app` provided)127128See `references/auth-app-integration.md` for all templates.129130Creates:1311. **Auth lib singleton** (`apps/<app>/src/lib/auth/index.ts`) - Zod env validation + lazy `getAuth()`1322. **Session functions** (`apps/<app>/src/lib/auth/functions.ts`) - `getSession`, `ensureSession` server functions1333. **Auth API** (`apps/<app>/src/core/api/auth.api.ts`) - `sendOtp`, `verifyOtp` (with `asResponse: true` cookie forwarding), `signOut`1344. **Route handler** (`apps/<app>/src/_routes/api/auth/$.ts`) - catch-all GET/POST delegating to `getAuth().handler()`1355. **Root route** - add `getSession()` in `beforeLoad`, return `session` in context1366. **Auth guard layout** (`apps/<app>/src/_routes/_authed/_layout.tsx`) - redirect to `/login` if no session1377. **Admin guard** (`apps/<app>/src/_routes/_authed/admin/_layout.tsx`) - redirect if `role !== "admin"`138139### Step 8 - Login UI (only if `--app` provided)140141See `references/auth-login-ui.md` for all component templates.142143Creates:1441. **Login page** (`/login/index.tsx`) - redirects if authenticated, renders LoginForm1452. **OTP page** (`/login/code-confirm.tsx`) - validates `?email=` search param, renders OtpVerificationForm1463. **OAuth callback** (`/auth/callback.tsx`) - immediate redirect (better-auth handles OAuth at `/api/auth/*`)1474. **LoginForm** - Google OAuth button + email input + OTP send using coss Card, Button, Input, Field1485. **OtpVerificationForm** - 6-digit InputOTP with auto-submit, resend, back using coss Card, Button, InputOTP1496. **Role utilities** - `UserRole` (re-exports `Role`), `getUserRole()`, `isAdmin()`, `useIsAdmin()` hook (all check lowercase `admin`)1507. **ViewMode context** - admin/user view switching for `admin` users151152### Step 9 - Environment Variables153154Create `.env.example` in project root:155156| Variable | Where | Purpose |157|---|---|---|158| `DATABASE_URL` | server | PostgreSQL connection string |159| `BETTER_AUTH_SECRET` | server | Session signing secret |160| `RESEND_API_KEY` | server | Email OTP delivery via Resend |161| `GOOGLE_CLIENT_ID` | server | Google OAuth credentials |162| `GOOGLE_CLIENT_SECRET` | server | Google OAuth credentials |163| `VITE_<APP>_SITE_URL` | both | Base URL (e.g. `http://localhost:3000`) |164165### Step 10 - Verify1661671. `bun install`1682. `bun test packages/<name>/src/permissions.test.ts` - RBAC roles pass1693. `bun run typecheck` - zero errors1704. `bun run schema` - generates Drizzle schema in db package1715. `bun run db:generate && bun run db:migrate` - creates auth tables in PostgreSQL1726. If app integration: dev server starts, `/login` renders, `/api/auth/ok` returns 200173174## Critical Gotchas175176- **`asResponse: true`** in `verifyOtp` is mandatory - without it, session cookies never reach the browser in SSR frameworks177- **Dynamic import** for Resend (`await import("resend")`) keeps it out of client bundles178- **Lazy singleton** `getAuth()` avoids module-level side effects that break SSR/prerendering179- **UUID generation** must use `advanced.database.generateId: "uuid"` - Drizzle adapter defaults to cuid otherwise180- **Cascading deletes** on session/account FKs - always set `onDelete: "cascade"` referencing user.id181- **Cookie forwarding** in `verifyOtp` - must read `set-cookie` from response and call `setResponseHeader("set-cookie", setCookie)`182- **Shared permissions** - `auth.config.ts`, `auth.client.ts`, and `auth.test-config.ts` must all import the same `ac`/`roles` from `./permissions`. Defining roles in only one place desyncs server/client authorization183- **Lowercase roles** - role values are `user`/`admin` (not `USER`/`ADMIN`); the `role` column is plain `text`, so a casing mismatch silently fails every admin check184- **`@better-auth/infra` import paths** - server uses `sentinel`/`dash` from `@better-auth/infra`; client uses `sentinelClient`/`dashClient` from `@better-auth/infra/client`. Swapping them breaks the build