Newsletter platform 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.
Audience-scoped newsletter platform — subscribers with deliverability status, named lists, list membership, campaigns scheduled against a list, and per-subscriber send tracking.
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
Newsletter schema: subscribers, lists, campaigns & per-subscriber sends
Subscribers & deliverability statusemail addresses collected by an owner, with a status CHECK walking subscribed/unsubscribed/bounced
Lists & list_subscriptions membershipnamed audience segments and the join table that places a subscriber on a list at most once
Campaigns & schedulingbroadcasts targeting a list, with a status CHECK gating draft/scheduled/sent and a nullable scheduledAt timestamp
Campaign_sends & delivery trackingone append-only row per (campaign, subscriber) tracking the queued→delivered→opened→bounced lifecycle
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`.
The (ownerId, email) unique constraint on subscribers prevents the same address appearing twice on one owner's audience — deduplication is enforced at the DB level, not application code.
campaign_sends is append-only per (campaign, subscriber): each row walks queued → delivered → opened → bounced via a text CHECK, making delivery/open rollups a straight aggregate over the idx_send_campaign index rather than a mutable counter.
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.