codexmachina
registry/react-router-mysql-better-auth-ai-wrapper

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

AI Wrapper

AI-wrapper app — per-user profile extending Better Auth, per-call token metering, and append-only prompt/completion history.

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;
}

AI app schema: user profiles, token metering & prompt logs

User profiles extending Better Auth

one profile per Better Auth user, carrying default model preference and soft monthly token budget

Per-call token usage & cost metering

append-only rows (one per upstream LLM call) that drive the per-user budget rollup and cost reporting

Prompt & completion log history

auditable record of every exchange — prompt, completion, token split, latency, and ok/error/filtered status

src/db/schema.ts
// === file: app/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  int,
  json,
  mysqlTable,
  text,
  timestamp,
  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 CallStatus = "ok" | "error" | "filtered";

/** One profile per Better Auth user — extends identity with AI-app preferences
 *  and the soft monthly token budget the metering layer enforces. */
export const userProfiles = mysqlTable(
  "user_profiles",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    // Better Auth's user.id is text — match it as varchar(255). One profile per
    // user, so the FK itself is unique (this is the 1:1 extension of `user`).
    userId: varchar("user_id", { length: 255 })
      .notNull()
      .unique()
      .references(() => user.id, { onDelete: "cascade" }),
    displayName: text("display_name"),
    // Default model this user's requests fall back to (e.g. gpt-4o-mini).
    defaultModel: varchar("default_model", { length: 255 }).notNull().default("gpt-4o-mini"),
    // Soft cap the metering layer checks token_usage against per calendar month.
    monthlyTokenBudget: bigint("monthly_token_budget", { mode: "number" })
      .notNull()
      .default(0),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [index("idx_profile_model").on(t.defaultModel)],
);

/** Append-only token meter: one row per upstream LLM call. Roll up by
 *  user + window for budget enforcement and cost reporting. */
export const tokenUsage = mysqlTable(
  "token_usage",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    userId: varchar("user_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    // Which upstream model was billed (provider model id, opaque to us).
    model: varchar("model", { length: 255 }).notNull(),
    promptTokens: int("prompt_tokens").notNull().default(0),
    completionTokens: int("completion_tokens").notNull().default(0),
    // ponytail: store cost in micro-cents as an integer — no float money, and
    // fine-grained enough for sub-cent per-token pricing without a numeric type.
    costMicrocents: bigint("cost_microcents", { mode: "number" })
      .notNull()
      .default(0),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the "tokens used this month" rollup + budget check.
    index("idx_usage_user_time").on(t.userId, t.createdAt),
    index("idx_usage_model").on(t.model),
  ],
);

/** Append-only prompt/completion history — the auditable record of every
 *  exchange (prompt in, completion out, model, token split, outcome). */
export const promptLogs = mysqlTable(
  "prompt_logs",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    userId: varchar("user_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    model: varchar("model", { length: 255 }).notNull(),
    prompt: text("prompt").notNull(),
    // Null while a streamed completion is still in flight.
    completion: text("completion"),
    promptTokens: int("prompt_tokens").notNull().default(0),
    completionTokens: int("completion_tokens").notNull().default(0),
    latencyMs: int("latency_ms"),
    status: varchar("status", { length: 32 }).$type<CallStatus>().notNull().default("ok"),
    // ponytail: opaque per-call metadata (tool calls, finish_reason, etc.) —
    // json bag beats a column-per-provider-field churn.
    metadata: json("metadata"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the per-user history feed (most-recent-first).
    index("idx_log_user_time").on(t.userId, t.createdAt),
    check(
      "prompt_logs_status_check",
      sql`${t.status} in ('ok','error','filtered')`,
    ),
  ],
);

export const userProfilesRelations = relations(userProfiles, ({ one }) => ({
  user: one(user, {
    fields: [userProfiles.userId],
    references: [user.id],
  }),
}));

export const tokenUsageRelations = relations(tokenUsage, ({ one }) => ({
  user: one(user, { fields: [tokenUsage.userId], references: [user.id] }),
}));

export const promptLogsRelations = relations(promptLogs, ({ one }) => ({
  user: one(user, { fields: [promptLogs.userId], 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

user_profiles.userId carries a UNIQUE constraint — it is the 1:1 extension of Better Auth's user row; the FK itself is the identity, so no separate join table is needed.

note

token_usage is append-only and stores cost as costMicrocents (bigint integer): no float money, fine-grained enough for sub-cent per-token pricing without a numeric type.

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.