Forum / community on React Router v8, 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
React Router v8 (framework mode) — SSR, config/file routes under app/, loaders/actions, and resource routes for API endpoints.
MySQL 8 via Drizzle ORM and the mysql2 driver.
Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.
Forum / community — slug-keyed category taxonomy, threaded discussions with open/locked status and pinning, and per-reply up/down votes.
Setup
bun add react-router 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
Forum / community schema: categories, threads, replies & votes
Categories taxonomyslug-unique top-level categories each thread must belong to exactly one of
Threads (status & pinning)threads scoped to a category and author, with open/locked status and an is_pinned flag for surfacing
Thread repliesflat replies within a thread, each carrying a body and an author FK into Better Auth's user table
Reply votesappend-style up/down votes on replies, uniquely constrained per (reply, voter) pair
Verified identity sync (Clerk)
Deploy targets
Decisions and compatibility
Framework mode (not data/library mode): routes live under app/, declared in app/routes.ts. API endpoints are resource routes (a route module exporting loader/action but no default component).
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`.
Vote identity is enforced by a composite unique on (reply_id, voter_id): one vote per reply per voter, value constrained to (-1, 1) via CHECK rather than a separate enum.
Thread status uses text + CHECK ('open','locked') so new statuses ship without an ALTER TYPE migration; the same pattern applies to vote value.
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.