Video platform on React Router v8, 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
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.
Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).
Video platform — user-owned channels with published videos, append-only view records, and ordered playlists.
Setup
bun add react-router 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
Video platform schema: channels, videos, views & playlists
Channelsuser-owned channel rows, each with a globally unique handle that anchors the channel's public URL
Videos & processing statevideos belonging to a channel, with a CHECK-constrained status lifecycle (processing → published | private) and nullable publishedAt
Viewsappend-only view events recording which video was watched, the optional viewer identity, and seconds watched
Playlistsordered collections of videos within a channel, with playlist_videos carrying a composite PK and explicit position column
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.
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.
channels.handle carries a unique constraint — URL-safe handle collisions are caught at the DB layer, not in application code.
video_views is append-only (no update path, composite index on videoId + createdAt): aggregate view counts and watch-time stats by rolling up rows rather than maintaining a running total.
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.