Database
Postgres with Drizzle ORM: local Supabase in development, schema push, and authorization in oRPC
Init uses Postgres with Drizzle ORM for type-safe queries - local Supabase in development (Postgres as a native process), a Supabase project in production by default. This guide covers the setup, schema, and development workflow.
Overview
The stack:
- Postgres - Postgres 17 from the Supabase CLI locally, a Supabase project (or any Postgres) in production
- Drizzle ORM - Type-safe queries over postgres.js
- drizzle-kit push - Schema sync, no migration files
- RLS deny-by-default - Authorization lives in oRPC, not the database
Architecture
graph TB
A[oRPC Procedures] --> B[Drizzle ORM]
B --> C[postgres.js]
C --> D[(Postgres)]
E[drizzle-schema.ts] --> F[drizzle-kit push]
F --> DClient
One server-side client, shared by the API and better-auth:
// packages/db/src/drizzle-client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { relations } from "./drizzle-relations";
const client = postgres(
// Locally `pnpm db:start` writes POSTGRES_URL; the fallback only lets code that
// never queries (a build, a unit test) import this
process.env.POSTGRES_URL ?? "postgresql://postgres:postgres@127.0.0.1:1/postgres",
// POSTGRES_URL is the pooled URL in production, and transaction-mode
// poolers do not support server-side prepared statements
{ prepare: false },
);
export const db = drizzle({ client, relations });POSTGRES_URL is the only connection env var the app reads; locally, pnpm db:start writes it into .env.local. drizzle-kit alone prefers POSTGRES_URL_NON_POOLING when set.
Schema
Two schema files and one relation graph:
packages/db/src/drizzle-relations.ts-defineRelationsover both schema files; it powersdb.query.*and is how the better-auth adapter finds its tablespackages/db/src/drizzle-schema.ts- application tables (waitlist,todo)packages/db/src/drizzle-schema-auth.ts- better-auth generated tables (user,session,account,verification,organization,member,invitation)
Application Tables
// packages/db/src/drizzle-schema.ts
// Unnamed columns take snake_case SQL names from this creator (organizationId -> organization_id)
const pgTable = pgTableCreator((name) => name, "snake_case");
export const waitlist = pgTable.withRLS("waitlist", (t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
userId: t.text().references(() => user.id, { onDelete: "set null" }),
source: t.text(),
email: t.text().notNull().unique(),
}));
export const todo = pgTable.withRLS(
"todo",
(t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
organizationId: t
.text()
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
title: t.text().notNull(),
description: t.text(),
completed: t.boolean().notNull().default(false),
createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(),
}),
(table) => [index("todo_organization_id_idx").on(table.organizationId)],
);Row Level Security
Every table is declared through pgTable.withRLS with no policies - deny by default. The app never hands Postgres to untrusted clients, so it's defense in depth rather than the primary control: it means a connection made with a non-owner role reads nothing by default. That includes Supabase's Data API, which serves the public schema to the anon and authenticated roles while it's on; the app doesn't use it, so turn it off (Deployment). Authorization lives in oRPC. The server's Drizzle connection is unaffected: the table owner bypasses RLS.
If you regenerate the auth schema, declare every table through pgTable.withRLS again.
Auth Tables
Generated from the better-auth config - don't hand-edit:
cd packages/db && pnpm generate:auth-schemaConfiguration
// packages/db/drizzle.config.ts
export default {
schema: ["./src/drizzle-schema-auth.ts", "./src/drizzle-schema.ts"],
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
// POSTGRES_URL_NON_POOLING ?? POSTGRES_URL - drizzle-kit needs a direct connection
url,
},
schemaFilter: ["public"],
} satisfies Config;Development Workflow
Init uses drizzle-kit push - schema goes straight to the database, no migration files.
pnpm db:start # Start local Postgres (Supabase)
pnpm db:stop # Stop local Postgres
pnpm db:push # Push schema to local database
pnpm db:push-remote # Push schema to production (.env.production.local)
pnpm db:reset # Reset local database, then push schemaAdding a Table
// 1. Define in packages/db/src/drizzle-schema.ts
export const post = pgTable.withRLS("post", (t) => ({
id: t.uuid().notNull().primaryKey().defaultRandom(),
organizationId: t
.text()
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
title: t.text().notNull(),
createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(),
}));
// 2. Wire it into the relation graph in packages/db/src/drizzle-relations.ts
export const relations = defineRelations({ ...schemaAuth, ...schema }, (r) => ({
// ...
post: {
organization: r.one.organization({ from: r.post.organizationId, to: r.organization.id }),
},
}));# 3. Push it
pnpm db:pushTypes flow automatically - no codegen step.
Query Patterns
// Relational query
const todos = await db.query.todo.findMany({
where: { organizationId: organization.id },
orderBy: { createdAt: "desc" },
});
// Insert
const [created] = await db.insert(todo).values({ organizationId, title }).returning();
// Update
await db
.update(todo)
.set({ completed: true, updatedAt: new Date() })
.where(and(eq(todo.id, id), eq(todo.organizationId, organizationId)));
// Delete
await db.delete(todo).where(eq(todo.id, id));
// Upsert-ish: ignore conflicts
await db.insert(waitlist).values(input).onConflictDoNothing({ target: waitlist.email });Infer types from the schema:
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
type Todo = InferSelectModel<typeof todo>;
type NewTodo = InferInsertModel<typeof todo>;Local Postgres
Local Supabase, from the Supabase CLI that pnpm install brings in. packages/db/supabase/config.toml turns on its stack backend ([experimental] stack, CLI 2.119+), whose native runtime runs Postgres as a plain process on macOS 14+ (Apple silicon) and Linux (glibc 2.35+). The config runs Postgres alone: the app uses none of Supabase's other services.
pnpm db:start # Start this branch's database; write its URL into .env.local
pnpm db:stop # Stop it, keep the data
pnpm db:reset # Wipe it, re-push the schema (and re-seed, from the root script)
pnpm -F db status # What the CLI is running for this checkout
pnpm -F db studio # Drizzle Studio, to browse dataThe CLI keeps one database per checkout and git branch, each with its own port from 20000 up, so clones, worktrees and parallel agents never share data or fight over a port. That's why the config pins no port: a pinned one makes a second branch's database fail to bind. pnpm db:start is two stock CLI calls: supabase start --runtime native, then supabase status --env --override-name DB_URL=POSTGRES_URL into .env.local, which the with-env scripts load before .env. A branch you haven't used before starts empty - pnpm db:push && pnpm db:seed there.
- The first start downloads Postgres (~400 MB, cached in
~/.supabase/cache); later starts take seconds. - Postgres refuses to run as root. Cloud sessions run as root, so the SessionStart hook in
.claude/settings.jsoncreates asupabase-postgressystem user and exportsSUPABASE_NATIVE_POSTGRES_USERfor the session; elsewhere as root, the CLI's error says how. - Intel Macs and Windows aren't supported: the native runtime needs Apple silicon or Linux.
- Keep
major_versioninconfig.tomlthe same as production's (SHOW server_version;).
Best Practices
- Scope by organization - Multi-tenant tables reference
organization.idand get an index on it - Check membership in oRPC - RLS won't save you; procedures must verify org access
- Use relational queries -
db.query.x.findMany({ with })beats N+1s - Let the pooler pool - Don't tune postgres.js connection counts; the pooler handles it
Troubleshooting
Connection errors
pnpm db:start # Start this branch's database and point .env at it
pnpm -F db status # What is running for this checkout?Schema drift
pnpm db:reset # Nuke local, re-push schemaPrepared statement errors in production
POSTGRES_URL is the pooled URL, and transaction-mode poolers do not support server-side prepared statements - the client must keep prepare: false.
Next Steps
- Authentication - How auth uses these tables
- API development - Build oRPC procedures with Drizzle queries