SaaS on Next.js 16 (App Router), MySQL 8 and Clerk
Type-checked against the real SDKs, migration applied to a live MySQL 8, 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).
MySQL 8 via Drizzle ORM and the mysql2 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 mysql2 @clerk/nextjsDATABASE_URLMySQL connection string (mysql://…)NEXT_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 scoping
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.
mysql2's pool multiplexes connections; drizzle-orm/mysql2 wraps it. One module-level pool is right for a serverless/edge app — the runtime and the pool handle concurrency.
MySQL has no row-level security: multi-tenant isolation is enforced in application code via the forOrg helper (src/lib/tenant.ts), not by the database. See the tenant-scoping section on SaaS pages.
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.
MySQL provides no row-level security. On MySQL, multi-tenant isolation is APP-ENFORCED via the forOrg helper (src/lib/tenant.ts), not database-enforced like Postgres RLS. Every org-scoped query MUST go through forOrg — a missed query leaks across tenants. Postgres cells enforce this in the database itself (RLS), so it holds even for a query that forgets to scope.