Database
PostgreSQL with Drizzle: the schema, and the generate-then-migrate loop.
Your database schema is TypeScript. The tables in packages/db/src/schema/ are the single source of truth: Drizzle Kit generates the SQL migration from them, and Drizzle types every query against them, so a renamed column is a compile error in the API, not a null in production.
You never write DDL by hand or edit the database directly. You edit the schema, generate the SQL, review it, and apply it.
Why Drizzle
Drizzle is not a query builder wrapped around SQL strings. Because the schema is plain TypeScript, the db client in packages/db/src/index.ts is fully typed from it, every select and insert returns typed rows, and Drizzle Kit writes each migration by diffing your schema against the last snapshot, so you review real SQL instead of trusting an ORM to guess.
The schema
The schema lives in packages/db/src/schema/, all re-exported from index.ts. Files group by concern, not by table, so a table joins the file its neighbours are in rather than getting one of its own:
auth.ts, the Better Auth tables:user,session,account,verification, plusorganization,member,team,teamMember, andinvitation, with their Drizzle relations and foreign-key indexes. See Auth & Organizations for what each one holds. This file is generated, never edited.console.ts, the console's own tables:allowlistbehind Console > Access, whosekindis a generated column so the rulevalueis the only thing a row stores, andactivitybehind Console > History, the trail of who changed what. They share a file because they are one concern, the same way the auth tables do.waitlist.ts: a standalonewaitlisttable (no foreign keys) behind the public/waitlistpage and Console > Waitlist.
auth.ts is generated
bun run auth:schema writes exactly what the Better Auth CLI emits for the plugins and columns declared in packages/auth/src/schema.ts. What follows from that:
- Never edit it by hand. A test fails when the committed file differs from a fresh generation (line endings aside, so a Windows checkout passes).
- A column of your own on
userorsessionis anadditionalFieldsentry inpackages/auth/src/schema.ts(the console'sroleSetAtis one), regenerated and then migrated like any other change. - The generator runs under Node on purpose. Bun prints
() => new Datefor a function's text, which is the check the CLI makes before it emits a database default, so a Bun run silently drops everydefaultNow(). The CLI loadspackages/auth/auth-schema.config.ts, which wraps the declaration in an instance with no database.
A fork keeps its own drizzle/ and schema across sync, so when the starter's auth tables move (a Better Auth upgrade adds a column, as 1.7 did for teams), take it the same way: bump the package, run bun run auth:schema, then bun run db:generate reproduces the migration from your own snapshot.
Conventions
Across the schema:
textprimary keys andsnake_casecolumn names.timestamp("created_at").defaultNow().notNull()for creation times.CASCADEon foreign-key deletes.- An index on most foreign-key columns, plus
verification.identifierandinvitation.email, which are not foreign keys;invitation.inviter_idcarries none.
Add indexes when your own row counts and query plans ask for them, not in advance. Where that choice shows:
user.rolehas no index and no database default. The console filters and sorts by it, and four distinct values over a table the size of your staff list is a sequential scan either way. The default is absent because the admin plugin stampsuseron every account it creates, and the generated schema says only what the plugin declares.- The
console.tstables break both rules above. Theiractor_idisSET NULL, notCASCADE, because deleting the admin who added a rule must not delete the rule, nor the history they made. Neither carries an index beyond its primary key, because at console sizes the sequential scan wins. Each also stores anactortext alongside the id, so a row stays readable once the id is null, and can name an actor that was never an account (a matching rule, or a script run from a terminal).
The generate → migrate loop
# 1. edit the schema in packages/db/src/schema/
# 2. generate a SQL migration from the diff
bun run db:generate
# 3. review the SQL in packages/db/drizzle/, then apply it
bun run db:migratedb:generate diffs your schema against the snapshot in packages/db/drizzle/meta/ and writes a numbered .sql file plus a fresh snapshot; it never touches the database. db:migrate runs the pending files against POSTGRES_URL. You review the SQL in between because it is the exact DDL that will run against your data.
Adding a table is the same loop. Create the file, export it, generate, migrate:
// packages/db/src/schema/project.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core"
import { organization } from "@/schema/auth"
export const project = pgTable("project", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
})Add export * from "@/schema/project" to packages/db/src/schema/index.ts, then run the two commands. The db-migration skill hands an agent this exact procedure, including the trap below.
The running stack won't see a new table until you rebuild
The API imports @packages/db's built output, and bun --hot does not reliably pick up new files. After generating and migrating, rebuild the package (bunx turbo run build --filter=@packages/db) and restart bun run dev. Never edit an applied migration; generate a new one instead.
In production
The schema, the SQL, and the snapshot travel together in the same commit as the change that needs them. On Vercel, the API build applies pending migrations automatically on production and canary deploys (.github/scripts/migrate-on-deploy.ts); PR previews are skipped. Point POSTGRES_URL at any managed Postgres: Neon, Supabase, Railway. For a throwaway local database, bunx pglaunch -k creates one instantly.
Browsing data
bun run db:studioDrizzle Studio opens a browser UI to read and edit rows directly.
Next
- Auth & Organizations: what the tables in
auth.tsactually do. - Environment Variables: where
POSTGRES_URLcomes from. - AI Skills: the
db-migrationskill and the rest of the catalog.