codexmachina
registry/react-router-mysql-better-auth-newsletter

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

Newsletter platform

Audience-scoped newsletter platform — subscribers with deliverability status, named lists, list membership, campaigns scheduled against a list, and per-subscriber send tracking.

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

Newsletter schema: subscribers, lists, campaigns & per-subscriber sends

Subscribers & deliverability status

email addresses collected by an owner, with a status CHECK walking subscribed/unsubscribed/bounced

Lists & list_subscriptions membership

named audience segments and the join table that places a subscriber on a list at most once

Campaigns & scheduling

broadcasts targeting a list, with a status CHECK gating draft/scheduled/sent and a nullable scheduledAt timestamp

Campaign_sends & delivery tracking

one append-only row per (campaign, subscriber) tracking the queued→delivered→opened→bounced lifecycle

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 SubscriberStatus = "subscribed" | "unsubscribed" | "bounced";
export type CampaignStatus = "draft" | "scheduled" | "sent";
export type CampaignSendStatus =
  | "queued"
  | "delivered"
  | "opened"
  | "bounced";

/** An email address on someone's audience. Owned by the user who collected it;
 *  status walks the deliverability lifecycle. The owner+email unique stops the
 *  same address landing on one owner's audience twice. */
export const subscribers = mysqlTable(
  "subscribers",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    // Better Auth's user.id is varchar(255) on MySQL — match it, don't recast.
    ownerId: varchar("owner_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    // Part of the owner+email unique below — MySQL can't index unbounded text.
    email: varchar("email", { length: 255 }).notNull(),
    status: varchar("status", { length: 32 })
      .$type<SubscriberStatus>()
      .notNull()
      .default("subscribed"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    unique("subscribers_owner_email_unique").on(t.ownerId, t.email),
    index("idx_subscriber_owner").on(t.ownerId),
    check(
      "subscribers_status_check",
      sql`${t.status} in ('subscribed','unsubscribed','bounced')`,
    ),
  ],
);

/** A named segment of an owner's audience (e.g. "Weekly digest"). */
export const lists = mysqlTable(
  "lists",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    ownerId: varchar("owner_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [index("idx_list_owner").on(t.ownerId)],
);

/** list <-> subscriber join. The composite unique is the membership identity —
 *  a subscriber is on a list at most once. */
export const listSubscriptions = mysqlTable(
  "list_subscriptions",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    listId: varchar("list_id", { length: 36 })
      .notNull()
      .references(() => lists.id, { onDelete: "cascade" }),
    subscriberId: varchar("subscriber_id", { length: 36 })
      .notNull()
      .references(() => subscribers.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    unique("list_subscriptions_list_subscriber_unique").on(
      t.listId,
      t.subscriberId,
    ),
    index("idx_list_subscription_subscriber").on(t.subscriberId),
  ],
);

/** A broadcast targeting a list. status walks the lifecycle; scheduledAt is the
 *  send time once status is 'scheduled'. */
export const campaigns = mysqlTable(
  "campaigns",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    listId: varchar("list_id", { length: 36 })
      .notNull()
      .references(() => lists.id, { onDelete: "cascade" }),
    subject: text("subject").notNull(),
    status: varchar("status", { length: 32 })
      .$type<CampaignStatus>()
      .notNull()
      .default("draft"),
    scheduledAt: timestamp("scheduled_at"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    index("idx_campaign_list").on(t.listId),
    check(
      "campaigns_status_check",
      sql`${t.status} in ('draft','scheduled','sent')`,
    ),
  ],
);

/** One row per (campaign, subscriber) send attempt. status walks delivery; the
 *  campaign index drives the per-campaign delivery/open rollup. */
export const campaignSends = mysqlTable(
  "campaign_sends",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    campaignId: varchar("campaign_id", { length: 36 })
      .notNull()
      .references(() => campaigns.id, { onDelete: "cascade" }),
    subscriberId: varchar("subscriber_id", { length: 36 })
      .notNull()
      .references(() => subscribers.id, { onDelete: "cascade" }),
    status: varchar("status", { length: 32 })
      .$type<CampaignSendStatus>()
      .notNull()
      .default("queued"),
    sentAt: timestamp("sent_at"),
  },
  (t) => [
    // Drives the "delivery/open stats for this campaign" rollup query.
    index("idx_send_campaign").on(t.campaignId),
    check(
      "campaign_sends_status_check",
      sql`${t.status} in ('queued','delivered','opened','bounced')`,
    ),
  ],
);

export const subscribersRelations = relations(subscribers, ({ one, many }) => ({
  owner: one(user, { fields: [subscribers.ownerId], references: [user.id] }),
  listSubscriptions: many(listSubscriptions),
  sends: many(campaignSends),
}));

export const listsRelations = relations(lists, ({ one, many }) => ({
  owner: one(user, { fields: [lists.ownerId], references: [user.id] }),
  listSubscriptions: many(listSubscriptions),
  campaigns: many(campaigns),
}));

export const listSubscriptionsRelations = relations(
  listSubscriptions,
  ({ one }) => ({
    list: one(lists, {
      fields: [listSubscriptions.listId],
      references: [lists.id],
    }),
    subscriber: one(subscribers, {
      fields: [listSubscriptions.subscriberId],
      references: [subscribers.id],
    }),
  }),
);

export const campaignsRelations = relations(campaigns, ({ one, many }) => ({
  list: one(lists, {
    fields: [campaigns.listId],
    references: [lists.id],
  }),
  sends: many(campaignSends),
}));

export const campaignSendsRelations = relations(campaignSends, ({ one }) => ({
  campaign: one(campaigns, {
    fields: [campaignSends.campaignId],
    references: [campaigns.id],
  }),
  subscriber: one(subscribers, {
    fields: [campaignSends.subscriberId],
    references: [subscribers.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

The (ownerId, email) unique constraint on subscribers prevents the same address appearing twice on one owner's audience — deduplication is enforced at the DB level, not application code.

note

campaign_sends is append-only per (campaign, subscriber): each row walks queued → delivered → opened → bounced via a text CHECK, making delivery/open rollups a straight aggregate over the idx_send_campaign index rather than a mutable counter.

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.