ZeroStarter

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, plus organization, member, team, teamMember, and invitation, 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: allowlist behind Console > Access, whose kind is a generated column so the rule value is the only thing a row stores, and activity behind 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 standalone waitlist table (no foreign keys) behind the public /waitlist page 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 user or session is an additionalFields entry in packages/auth/src/schema.ts (the console's roleSetAt is one), regenerated and then migrated like any other change.
  • The generator runs under Node on purpose. Bun prints () => new Date for a function's text, which is the check the CLI makes before it emits a database default, so a Bun run silently drops every defaultNow(). The CLI loads packages/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:

  • text primary keys and snake_case column names.
  • timestamp("created_at").defaultNow().notNull() for creation times.
  • CASCADE on foreign-key deletes.
  • An index on most foreign-key columns, plus verification.identifier and invitation.email, which are not foreign keys; invitation.inviter_id carries none.

Add indexes when your own row counts and query plans ask for them, not in advance. Where that choice shows:

  • user.role has 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 stamps user on every account it creates, and the generated schema says only what the plugin declares.
  • The console.ts tables break both rules above. Their actor_id is SET NULL, not CASCADE, 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 an actor text 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:migrate

db: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:studio

Drizzle Studio opens a browser UI to read and edit rows directly.

Next