SaaS on Next.js 16 (App Router), Postgres (Neon) and Clerk
Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify
session validation runs in server components and route handlers, not at the edge
What you're getting
Next.js 16 App Router — file-based routing, server components, and the Edge proxy (Next 16's renamed middleware).
Postgres on Neon via Drizzle ORM and the postgres-js driver.
Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.
Multi-tenant SaaS — organizations, role-based memberships, plans/subscriptions, and credit metering.
Setup
bun add next react react-dom drizzle-orm postgres @clerk/nextjsDATABASE_URLNeon pooled (-pooler) connection stringNEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEYCLERK_WEBHOOK_SECRETsvix secret that verifies Clerk webhook signaturesApply the schema with bunx drizzle-kit push
Initialization
Database client
Multi-tenant SaaS schema: organizations, billing & usage metering
Organizations & multi-tenancythe tenant boundary every billable and metered row hangs off
Memberships & role-based accessorg↔user join carrying owner/admin/member roles, unique per pair
Plans & subscription billing tablesthe billable plan catalog and each org's current subscription state
API usage & credit/token meteringappend-only usage rows that drive quota checks and usage billing
Verified billing (Polar)
Verified tenant isolation
Verified identity sync (Clerk)
Deploy targets
Decisions and compatibility
Auth runs in proxy.ts (Next 16's renamed middleware) on the Edge runtime: it gates on the session cookie's presence only — full session validation happens in Server Components and route handlers, not in the proxy.
prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.
Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.
Hosted: Clerk owns identity and does NOT create a local `user` table. Store `clerk_user_id` as text without a foreign key, or sync Clerk users into a local table via webhook before relying on FKs to `user`.
One active subscription per organization (unique on organization_id) — the metering layer reads exactly one.
Usage is an append-only meter (api_usage): roll up by organization + time window for quota and billing rather than mutating a running total.
Clerk is a hosted identity provider and does not create a local `user` table. This schema's foreign keys to `user` assume a local identity table (as Better Auth provides). With Clerk, store `clerk_user_id` as a text column without a foreign key, or sync Clerk users into a local `users` table via webhook before relying on these FKs.