Product analytics 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.
Product-analytics schema — tracked apps with unique ingest keys, an append-only event stream, session windows, and saved funnels, all hung off Better Auth's user table.
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
Product analytics schema: tracked apps, events, sessions & funnels
Tracked apps & ingest keysowner-scoped app registrations, each with a unique api_key used to authenticate inbound event writes
Events (append-only)the raw, immutable event stream keyed by app and timestamp, with freeform JSONB properties and an optional anonymous visitor id
Sessionsvisitor session windows (start/end timestamps) correlated to events via anonymous_id, with nullable ended_at for open sessions
Funnelssaved multi-step funnel definitions stored as JSONB steps arrays, scoped per tracked app
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`.
api_key on tracked_apps carries a unique constraint: each app gets exactly one ingest key, and the key is the authentication surface for the event ingestion endpoint.
events is append-only (no update path, no status column) — the composite index on (app_id, occurred_at) is the only query handle; roll-up queries must scan that index, not mutate rows.
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.