Data Layer
Postgres · Drizzle ORM · Supabase · Migrations
The data layer is the only place where SaaS bets stop being reversible: the schema you ship in week 2 is the schema you'll fight in year 4. Pick Postgres (versatile, RLS-native, the entire 2026 SaaS stack assumes it), use Drizzle for type-safe queries that don't fight RLS, and bake migrations + backups into deployment from day one. Every table gets org_id, every query goes through a single tenant-aware helper, every change goes through a migration — no exceptions.
Primary stack
The default picks this pillar assumes
- SupabaseFree tier; Pro $25/mo + usa…
Alternates worth knowing
Swap in when scale, cost or constraints change
- Neon
Postgres
The OLTP database. Managed Postgres (Supabase or Neon) gets you point-in-time recovery, branch databases, and an HTTP gateway out of the box. Don't run Postgres yourself — the operational cost is real and the failover stories are worse than you think.
How it flows
step by step- 01step
Provisioned via Terraform / vendor UI with row-level security ON globally.
- 02step
Two roles: `authenticated` (used by the app via JWT) and `service_role` (admin / migrations).
- 03step
Connection pooling via PgBouncer / Supavisor (vendor-provided) — never connect directly from serverless.
- 04step
Read replicas for heavy analytics queries (Phase 2; default to one writer until you have evidence).
Cases & edge cases
watch for theseServerless cold starts kill direct Postgres connections — always use the HTTP gateway or a pool.
Don't enable extensions you don't need (pg_cron, pgvector, postgis) — every extension is a version-pin risk.
Replication lag on read replicas → never read your own writes from a replica; mark write-followed reads.
Vendors & tools
what implements thisManaged Postgres with auth + storage + realtime baked in.
pricingFree tier; Pro $25/mo + usage
Pros
- RLS is first-class
- Drizzle has @supabase imports
- Edge-friendly via HTTP gateway
Cons
- Newer than RDS — less battle-tested at extreme scale (10M+ rows on hot tables)
Serverless Postgres with branching (DB-per-PR).
Pros
- Branching for preview deploys is magic
- Scales to zero
Cons
- No built-in auth; you'll need Clerk or similar
Decisions
pick once, live with itDecision 01
Supabase or Neon for new SaaS in 2026?
pickSupabase if you want the integrated stack (auth + storage + realtime); Neon if you're combining with Clerk and want branching.
whyBoth are excellent. Supabase wins on integration density, Neon on scale-to-zero + DB-per-PR.
Drizzle ORM
SQL-first, schema-as-code ORM. Inferred types, no decorators, doesn't fight RLS. Queries read like SQL but with autocomplete. Replaces Prisma for most 2026 Next.js SaaS builds.
How it flows
step by step- 01step
Schema declared in drizzle/schema/*.ts as table objects (pgTable).
- 02step
Queries: db.select().from(orgs).where(eq(orgs.slug, slug)) — types inferred end-to-end.
- 03step
RLS-aware: queries run inside withTenant() which sets the org_id session var.
- 04step
Migrations: drizzle-kit generate diffs schema vs DB → emits SQL → human reviews → drizzle-kit migrate applies.
Cases & edge cases
watch for theseJoins with RLS: the policy applies to the OUTER row; joined tables also need policies. Don't assume.
Avoid prepared statements at the pool boundary in serverless — bypass the pool, use the HTTP gateway.
Don't pre-fetch into TypeScript when you can filter in SQL. The DB's smarter than your `.filter(...)`.
Checklist
ship readiness- must
Schema in code, migrations in version control
- must
withTenant() wrapper used everywhere
- should
Drizzle's logger enabled in dev
Where it lives
code references- drizzle/schema/
- drizzle.config.ts
- lib/db/index.ts // pooled client + withTenant
Schema Design
Conventions that compound: UUIDv7 IDs (sortable, K-orderable), created_at/updated_at on every table, soft-delete with deleted_at, org_id NOT NULL with RLS, JSONB for genuinely-variable data only.
How it flows
step by step- 01step
Every table: id uuid PK DEFAULT uuid_generate_v7(), org_id uuid NOT NULL FK, created_at, updated_at.
- 02step
Soft-delete: deleted_at timestamptz; partial index WHERE deleted_at IS NULL on hot lookups.
- 03step
Naming: snake_case in DB, camelCase in TS, Drizzle maps via `name:` in column defs.
- 04step
Foreign keys: ON DELETE CASCADE for child-to-parent within an org; RESTRICT for cross-org refs.
Cases & edge cases
watch for theseUUIDv4 IDs kill BTree locality → use UUIDv7 (time-ordered).
updated_at via trigger, not app code — app code forgets.
Soft-delete is forever or it isn't — once you ship it, you can't quietly hard-delete or it breaks history.
Checklist
ship readiness- should
UUIDv7 (not v4) PKs
- must
created_at + updated_at via trigger
- should
Partial indexes on deleted_at IS NULL
Where it lives
code references- drizzle/schema/_helpers.ts // baseColumns(), softDelete()
Notes
marginAdd `created_by` (user uuid) and `updated_by` to every audit-relevant table. Costs 16 bytes, saves your investigation.
Migrations
Forward-only SQL migrations checked into the repo, applied by a CI job before deployment. Never edit a shipped migration. Rollback is a new forward migration (a 'reverse').
How it flows
step by step- 01step
Edit schema → pnpm drizzle-kit generate → produces drizzle/migrations/NNNN_<name>.sql.
- 02step
Review the SQL by hand. Edit if needed (e.g. add CONCURRENTLY).
- 03step
Commit → CI runs migration against staging on PR open.
- 04step
Deploy → migration step runs before app boot. If it fails, deploy is blocked.
Cases & edge cases
watch for theseCREATE INDEX without CONCURRENTLY locks the table — production migrations must use CONCURRENTLY.
Renaming a column is a 3-step dance: add new col → backfill + dual-write → drop old col (two deploys).
Lock timeout: SET lock_timeout = '5s' protects you from a slow migration starving writes.
Checklist
ship readiness- must
lock_timeout configured in every migration script
- must
CONCURRENTLY for index creation on tables >100k rows
- must
Forward-only — never edit a merged migration
Where it lives
code references- drizzle/migrations/
- .github/workflows/migrate.yml
Indexes & Performance
Multi-tenant Postgres lives or dies on index design: composite indexes leading with org_id, partial indexes for soft-delete + status filters, BRIN for time-series. EXPLAIN ANALYZE every slow query before optimizing further down the stack.
How it flows
step by step- 01step
Identify hot queries from logs (slow query log, pg_stat_statements).
- 02step
EXPLAIN ANALYZE — confirm it's not using an index or is doing a sequential scan.
- 03step
Add composite index: (org_id, <filter_col>, <sort_col>).
- 04step
Re-EXPLAIN — confirm plan changed and rows visited dropped.
- 05step
Commit + monitor query metrics in prod for a week.
Cases & edge cases
watch for theseDon't pre-index. Every index slows writes. Wait for evidence (slow query, pg_stat_statements top entry).
Composite indexes are leading-prefix sensitive: (org_id, status) supports WHERE org_id=… AND status=…, NOT WHERE status=…
JSONB indexes use GIN — only when you query JSONB paths repeatedly. Most JSONB columns don't need an index.
Where it lives
code references- drizzle/migrations/ // index DDL
Notes
marginHave pg_stat_statements enabled and a weekly 'top 20 slow queries' review until you have <5 over the threshold.
Backups & PITR
Supabase + Neon include PITR (point-in-time recovery) on paid plans. Daily logical dumps offsite for true paranoid recovery. Test the restore quarterly — backups you haven't restored aren't backups.
How it flows
step by step- 01step
Managed provider runs continuous WAL archiving → 7-day PITR window standard.
- 02step
Nightly pg_dump → encrypted to a separate cloud bucket (different vendor if possible).
- 03step
Quarterly: spin up a new DB from yesterday's dump, run smoke tests, document the time.
- 04step
Annual: full disaster recovery drill from offsite backup.
Cases & edge cases
watch for thesePITR doesn't survive vendor account compromise — offsite dumps do.
Encrypt backups with a key NOT stored in the same vendor (KMS in a separate AWS account or similar).
Verify restore time. If it's > your RTO commitment, escalate to a hot standby pattern.
Checklist
ship readiness- must
PITR enabled (managed Postgres)
- must
Offsite dumps to a different vendor
- must
Restore tested quarterly with documented time
Audit Trail
An append-only table that logs every mutation: actor, action, target, before/after. Required for SOC 2, indispensable for support, prevents 90% of 'who deleted my data' tickets.
How it flows
step by step- 01step
Every server action that mutates state calls audit.log(actor, action, targetId, diff).
- 02step
Trigger-based audit on the DB side as a defense-in-depth (catches direct SQL).
- 03step
Audit table is partitioned by month, vacuum-aware, never updated/deleted.
- 04step
Export view for customer-facing 'activity log' surfaces.
Cases & edge cases
watch for theseDon't put the audit table inside the same RLS perimeter — admins need cross-tenant queries.
Diffs are JSON of changed fields, not full snapshots — keeps the row size manageable.
Audit entries are never edited. Append-only. If you need to redact (GDPR), tombstone with a reason.
Where it lives
code references- drizzle/schema/audit-log.ts
- lib/audit/log.ts
Notes
marginIf you can't answer 'who did X on Tuesday at 3pm and what changed?', your audit trail is broken — fix it before someone asks.
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.