Project management on Next.js 16 (App Router), MySQL 8 and Better Auth
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.
Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).
Project-management layer — user-owned projects, kanban tasks with status/priority, many-to-many assignees, per-project labels, and comment threads.
Setup
bun add next react react-dom drizzle-orm mysql2 better-authDATABASE_URLMySQL connection string (mysql://…)BETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`BETTER_AUTH_URLyour app's base URLApply the schema with bunx drizzle-kit push
Initialization
Database client
Project-management schema: projects, tasks, assignees, labels & comments
Projects owned by a usertop-level containers keyed to a Better Auth user via owner_id FK, with active/archived status
Tasks with status, priority & due datethe unit of work scoped to a project, with a composite (project_id, status) index driving board-column queries
Task assignees & per-project labelstask↔user assignment join (unique per pair) and a project-scoped label catalog with a task↔label join table
Task comment threadsappend-only comment rows keyed to a task and a Better Auth author, indexed on (task_id, created_at) for chronological feeds
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.
Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.
task_assignees carries a composite unique on (task_id, assignee_id) — a user can be assigned to a task at most once; the per-user index on assignee_id backs the 'tasks assigned to me' feed.
Status and priority are stored as text + CHECK (not pgEnum) so new values like 'blocked' or 'critical' ship without an ALTER TYPE migration dance.
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.