codexmachina
registry/react-router-mysql-better-auth-project-management

Project management on React Router v8, MySQL 8 and Better Auth

verified 2026-07-15mysql23.22.6postgres3.4.9better-auth1.6.23react-router8.2.0@neondatabase/serverless1.1.0

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

request path
Browserrequest
fetch
React Router v8routing + proxy
verify
Better Authsession
query
MySQL 8pooled

session validation runs in server components and route handlers, not at the edge

What you're getting

React Router v8

React Router v8 (framework mode) — SSR, config/file routes under app/, loaders/actions, and resource routes for API endpoints.

MySQL 8

MySQL 8 via Drizzle ORM and the mysql2 driver.

Better Auth

Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).

Project management

Project-management layer — user-owned projects, kanban tasks with status/priority, many-to-many assignees, per-project labels, and comment threads.

Setup

bun add react-router react react-dom drizzle-orm mysql2 better-auth
DATABASE_URLMySQL connection string (mysql://…)
BETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`
BETTER_AUTH_URLyour app's base URL

Apply the schema with bunx drizzle-kit push

Initialization

Database client

app/lib/db.ts
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";

// ponytail: single module-level pool; the runtime + mysql2's pool handle concurrency,
// so no globalThis singleton dance needed.
const pool = mysql.createPool(process.env.DATABASE_URL!);

export const db = drizzle({ client: pool });
import { boolean, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";

export const user = mysqlTable("user", {
  id: varchar("id", { length: 255 }).primaryKey(),
  name: text("name").notNull(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const session = mysqlTable("session", {
  id: varchar("id", { length: 255 }).primaryKey(),
  expiresAt: timestamp("expires_at").notNull(),
  token: varchar("token", { length: 255 }).notNull().unique(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: varchar("user_id", { length: 255 })
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
});

export const account = mysqlTable("account", {
  id: varchar("id", { length: 255 }).primaryKey(),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: varchar("user_id", { length: 255 })
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  password: text("password"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
});

export const verification = mysqlTable("verification", {
  id: varchar("id", { length: 255 }).primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Better Auth instance (self-hosted, React Router v8).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// app/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
import { db } from "./db";
import * as authSchema from "@/db/auth-schema";

export const auth = betterAuth({
  // Drizzle adapter over the shared client. The schema is passed HERE (not into
  // drizzle()) — it is the only consumer that needs it, so the db client stays
  // schema-less. ponytail: do NOT enable experimental.joins; it is the one option
  // that would make the adapter reach into db._.fullSchema.
  // ponytail: usePlural stays false (the default) — with it true Better Auth would
  // look for `sessions`/`accounts` and could bind to the analytics/ledger app-type tables.
  database: drizzleAdapter(db, { provider: "mysql", schema: authSchema }),
  // ponytail: email+password is the shortest real auth that works out of
  // the box — add socialProviders / plugins here when the app needs them.
  emailAndPassword: { enabled: true },
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

export type Session = typeof auth.$Infer.Session;
// Better Auth mounted as a React Router resource route (a route module with NO
// default component). auth.handler is framework-agnostic — (Request) => Response —
// so the GET loader and the POST/etc. action both delegate straight to it. The
// `$` splat catches every /api/auth/* sub-path Better Auth routes internally.
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { auth } from "@/lib/auth";

export function loader({ request }: LoaderFunctionArgs) {
  return auth.handler(request);
}

export function action({ request }: ActionFunctionArgs) {
  return auth.handler(request);
}
// Session gate — the RR7 replacement for Next's Edge proxy. Protected loaders
// call `await requireAuth(request)`. Unlike the proxy's cookie-existence check,
// this does the REAL server-side validation via auth.api.getSession, then throws
// a redirect Response (React Router short-circuits the loader on a thrown Response)
// to bounce logged-out users before the protected data ever loads.
import { redirect } from "react-router";
import { auth } from "@/lib/auth";

export async function requireAuth(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) {
    throw redirect("/sign-in");
  }
  return session;
}

Project-management schema: projects, tasks, assignees, labels & comments

Projects owned by a user

top-level containers keyed to a Better Auth user via owner_id FK, with active/archived status

Tasks with status, priority & due date

the unit of work scoped to a project, with a composite (project_id, status) index driving board-column queries

Task assignees & per-project labels

task↔user assignment join (unique per pair) and a project-scoped label catalog with a task↔label join table

Task comment threads

append-only comment rows keyed to a task and a Better Auth author, indexed on (task_id, created_at) for chronological feeds

src/db/schema.ts
// === file: app/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  check,
  index,
  mysqlTable,
  text,
  timestamp,
  unique,
  varchar,
} from "drizzle-orm/mysql-core";
// Better Auth owns identity; we only reference its `user` table by id.
import { user } from "./auth-schema";

export type ProjectStatus = "active" | "archived";
export type TaskStatus = "todo" | "in_progress" | "done";
export type TaskPriority = "low" | "medium" | "high" | "urgent";

/** Top-level container. Every task/label hangs off a project; the owner is a
 *  Better Auth user referenced by id (varchar), never redeclared here. */
export const projects = mysqlTable(
  "projects",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    // Better Auth's user.id is text — match it as varchar(255).
    ownerId: varchar("owner_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    status: varchar("status", { length: 32 })
      .$type<ProjectStatus>()
      .notNull()
      .default("active"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    index("idx_project_owner").on(t.ownerId),
    check(
      "projects_status_check",
      sql`${t.status} in ('active','archived')`,
    ),
  ],
);

/** The unit of work. Scoped to a project; status/priority drive the board, and
 *  the project+status index backs the "tasks in this column" query. */
export const tasks = mysqlTable(
  "tasks",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    projectId: varchar("project_id", { length: 36 })
      .notNull()
      .references(() => projects.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    status: varchar("status", { length: 32 })
      .$type<TaskStatus>()
      .notNull()
      .default("todo"),
    priority: varchar("priority", { length: 32 })
      .$type<TaskPriority>()
      .notNull()
      .default("medium"),
    dueDate: timestamp("due_date"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the board: tasks in a project, grouped by column/status.
    index("idx_task_project_status").on(t.projectId, t.status),
    check(
      "tasks_status_check",
      sql`${t.status} in ('todo','in_progress','done')`,
    ),
    check(
      "tasks_priority_check",
      sql`${t.priority} in ('low','medium','high','urgent')`,
    ),
  ],
);

/** task <-> user assignment. The composite unique is the assignment identity
 *  (a user is assigned to a task at most once); the user index backs "my tasks". */
export const taskAssignees = mysqlTable(
  "task_assignees",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    taskId: varchar("task_id", { length: 36 })
      .notNull()
      .references(() => tasks.id, { onDelete: "cascade" }),
    assigneeId: varchar("assignee_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    unique("task_assignees_task_user_unique").on(t.taskId, t.assigneeId),
    // Drives the per-user "tasks assigned to me" feed.
    index("idx_assignee_user").on(t.assigneeId),
  ],
);

/** Per-project label catalog. color is an opaque CSS token (e.g. #ff0000). */
export const labels = mysqlTable(
  "labels",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    projectId: varchar("project_id", { length: 36 })
      .notNull()
      .references(() => projects.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    color: varchar("color", { length: 32 }).notNull().default("#94a3b8"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [index("idx_label_project").on(t.projectId)],
);

/** task <-> label join. The composite unique keeps a label on a task once. */
export const taskLabels = mysqlTable(
  "task_labels",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    taskId: varchar("task_id", { length: 36 })
      .notNull()
      .references(() => tasks.id, { onDelete: "cascade" }),
    labelId: varchar("label_id", { length: 36 })
      .notNull()
      .references(() => labels.id, { onDelete: "cascade" }),
  },
  (t) => [
    unique("task_labels_task_label_unique").on(t.taskId, t.labelId),
    index("idx_task_label_label").on(t.labelId),
  ],
);

/** Comment thread per task. author is a Better Auth user referenced by id. */
export const taskComments = mysqlTable(
  "task_comments",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    taskId: varchar("task_id", { length: 36 })
      .notNull()
      .references(() => tasks.id, { onDelete: "cascade" }),
    authorId: varchar("author_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    body: text("body").notNull(),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the per-task comment feed (most-recent-first).
    index("idx_comment_task_time").on(t.taskId, t.createdAt),
  ],
);

export const projectsRelations = relations(projects, ({ one, many }) => ({
  owner: one(user, { fields: [projects.ownerId], references: [user.id] }),
  tasks: many(tasks),
  labels: many(labels),
}));

export const tasksRelations = relations(tasks, ({ one, many }) => ({
  project: one(projects, {
    fields: [tasks.projectId],
    references: [projects.id],
  }),
  assignees: many(taskAssignees),
  labels: many(taskLabels),
  comments: many(taskComments),
}));

export const taskAssigneesRelations = relations(taskAssignees, ({ one }) => ({
  task: one(tasks, {
    fields: [taskAssignees.taskId],
    references: [tasks.id],
  }),
  assignee: one(user, {
    fields: [taskAssignees.assigneeId],
    references: [user.id],
  }),
}));

export const labelsRelations = relations(labels, ({ one, many }) => ({
  project: one(projects, {
    fields: [labels.projectId],
    references: [projects.id],
  }),
  tasks: many(taskLabels),
}));

export const taskLabelsRelations = relations(taskLabels, ({ one }) => ({
  task: one(tasks, {
    fields: [taskLabels.taskId],
    references: [tasks.id],
  }),
  label: one(labels, {
    fields: [taskLabels.labelId],
    references: [labels.id],
  }),
}));

export const taskCommentsRelations = relations(taskComments, ({ one }) => ({
  task: one(tasks, {
    fields: [taskComments.taskId],
    references: [tasks.id],
  }),
  author: one(user, {
    fields: [taskComments.authorId],
    references: [user.id],
  }),
}));

Deploy targets

✓ The right DB client for where you deploy: load-tested with concurrent queries against a live database. Edge needs the HTTP driver (no TCP); serverless needs a tiny pool.
src/lib/db.ts
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";

// Serverless: a small pool per short-lived instance — many instances × a big pool exhausts MySQL.
export const pool = mysql.createPool({ uri: process.env.DATABASE_URL!, connectionLimit: 2 });
export const db = drizzle({ client: pool });
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";

// Long-running process: a real, reused pool (mysql2 manages idle recycling).
export const pool = mysql.createPool({ uri: process.env.DATABASE_URL!, connectionLimit: 10 });
export const db = drizzle({ client: pool });
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { Client } from "@planetscale/database";

// Edge/Workers have NO TCP sockets, so mysql2 cannot run here. PlanetScale's HTTP driver
// speaks MySQL over fetch — the client that works on Workers (the MySQL analog of Neon's HTTP driver).
const client = new Client({ url: process.env.DATABASE_URL! });
export const db = drizzle({ client });

Decisions and compatibility

note

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).

note

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.

note

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.

note

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.

note

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.

note

Status and priority are stored as text + CHECK (not pgEnum) so new values like 'blocked' or 'critical' ship without an ALTER TYPE migration dance.

caveat

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.