CRM 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.
Sales CRM — companies, contacts, named pipelines, deal-stage tracking, and an append-only activity trail.
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
Sales CRM schema: companies, contacts, pipelines, deals & activities
Companies & contactsaccount records (companies de-duped on domain) and the contacts that belong to them, with nullable company FK to support unattached leads
Pipelines & dealsnamed sales pipelines and the opportunities (deals) moving through them, each carrying a contact FK, an owner FK to Better Auth user, and amount stored as integer cents
Deal stage trackingstage column on deals enforced by CHECK constraint with an index that powers pipeline-board grouping by stage
Activity interaction logappend-only calls/emails/notes logged against a deal by a Better Auth actor, forming the per-deal audit timeline
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`.
Deal stage is text + CHECK ('lead','qualified','won','lost') on the deals table — new stages ship without an ALTER TYPE migration; the idx_deal_stage index drives the pipeline board's 'deals grouped by stage' query.
activities is append-only (no updates, no deletes cascaded from deal): the per-deal timeline is always a raw log, never a mutated summary, queried via idx_activity_deal_time (deal_id, occurred_at).
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.