codexmachina
registry/nuxt-mysql-better-auth-crm

CRM on Nuxt 4, MySQL 8 and Better Auth

verified 2026-07-15nuxt4.4.8mysql23.22.6postgres3.4.9better-auth1.6.23@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
Nuxt 4routing + proxy
verify
Better Authsession
query
MySQL 8pooled

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

What you're getting

Nuxt 4

Nuxt 4 (framework mode) — full-stack Vue SSR: client under app/ (Vite), server under server/ (Nitro), API as server/api/*.post.ts Nitro route handlers.

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

CRM

Sales CRM — companies, contacts, named pipelines, deal-stage tracking, and an append-only activity trail.

Setup

bun add nuxt vue 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

server/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, Nuxt 4).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// server/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
// Server code lives under server/ (Nitro); reach it via Nuxt's `~~` rootDir alias.
import { db } from "~~/server/lib/db";
import * as authSchema from "~~/server/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 Nitro catch-all route. auth.handler is framework-
// agnostic — (Request) => Promise<Response> — and toWebRequest adapts the H3
// event into a web Request. The `[...all]` param catches every /api/auth/*
// sub-path Better Auth routes internally.
// ponytail: Nuxt AUTO-IMPORTS defineEventHandler/toWebRequest from Nitro/H3 at
// runtime; we import them EXPLICITLY from h3 (Nitro's engine, a nuxt dep) so this
// handler type-checks under standalone tsc — the one idiom trade for verifiability.
import { defineEventHandler, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler((event) => auth.handler(toWebRequest(event)));
// Vue/Nuxt client binding. better-auth/vue exposes the framework-appropriate client
// (signIn / signUp / useSession as Vue refs) — the Vue analog of the React client.
import { createAuthClient } from "better-auth/vue";

export const authClient = createAuthClient();
// Session gate — the Nuxt analog of Next's Edge proxy / RR8's requireAuth. A Nitro
// server middleware runs on every SSR/API request; guard the app surface and do the
// REAL server-side session check (like RR8 — stronger than Next's cookie-existence peek).
// ponytail: this guards SSR loads + direct hits + API — the true security boundary. For
// client-side SPA navigation add an app/middleware/*.ts route middleware (authored with
// Nuxt auto-imports; NOT tsc-gated, since plain tsc can't resolve them — spec §6).
// Explicit h3 imports so this type-checks standalone (see the handler note above).
import { defineEventHandler, getRequestURL, sendRedirect, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler(async (event) => {
  const { pathname } = getRequestURL(event);
  // ponytail: guard the SaaS app surface; widen the prefixes per app-type.
  if (!pathname.startsWith("/dashboard") && !pathname.startsWith("/settings")) return;
  const session = await auth.api.getSession({ headers: toWebRequest(event).headers });
  if (!session) return sendRedirect(event, "/sign-in", 302);
});

Sales CRM schema: companies, contacts, pipelines, deals & activities

Companies & contacts

account records (companies de-duped on domain) and the contacts that belong to them, with nullable company FK to support unattached leads

Pipelines & deals

named sales pipelines and the opportunities (deals) moving through them, each carrying a contact FK, an owner FK to Better Auth user, and amount stored as integer cents

Deal stage tracking

stage column on deals enforced by CHECK constraint with an index that powers pipeline-board grouping by stage

Activity interaction log

append-only calls/emails/notes logged against a deal by a Better Auth actor, forming the per-deal audit timeline

src/db/schema.ts
// === file: server/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  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 DealStage = "lead" | "qualified" | "won" | "lost";
export type ActivityType = "call" | "email" | "note";

/** Account record: the organization a set of contacts belongs to. */
export const companies = mysqlTable(
  "companies",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    name: text("name").notNull(),
    // Primary web domain — opaque key we de-dupe accounts on.
    domain: varchar("domain", { length: 255 }),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [index("idx_company_domain").on(t.domain)],
);

/** A person we sell to. Company is nullable (a lead can exist before we know
 *  their employer); owner is the Better Auth user accountable for the contact. */
export const contacts = mysqlTable(
  "contacts",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    // Nullable: unattached leads get a company once qualified.
    companyId: varchar("company_id", { length: 36 }).references(() => companies.id, {
      onDelete: "set null",
    }),
    email: text("email").notNull(),
    name: text("name"),
    // Better Auth's user.id is text — match it as varchar(255).
    ownerId: varchar("owner_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    index("idx_contact_company").on(t.companyId),
    index("idx_contact_owner").on(t.ownerId),
  ],
);

/** Named sales pipeline deals move through (e.g. Inbound, Enterprise). */
export const pipelines = mysqlTable("pipelines", {
  id: varchar("id", { length: 36 }).primaryKey(),
  name: text("name").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

/** An open/closed opportunity against a contact, in a pipeline, owned by a
 *  Better Auth user. amount in integer cents (no float money). */
export const deals = mysqlTable(
  "deals",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    contactId: varchar("contact_id", { length: 36 })
      .notNull()
      .references(() => contacts.id, { onDelete: "cascade" }),
    pipelineId: varchar("pipeline_id", { length: 36 })
      .notNull()
      .references(() => pipelines.id),
    ownerId: varchar("owner_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    // ponytail: money as integer cents — no float, no numeric type churn.
    amountCents: bigint("amount_cents", { mode: "number" })
      .notNull()
      .default(0),
    stage: varchar("stage", { length: 32 }).$type<DealStage>().notNull().default("lead"),
    closeDate: timestamp("close_date"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the pipeline board "deals grouped by stage" query.
    index("idx_deal_stage").on(t.stage),
    index("idx_deal_contact").on(t.contactId),
    check(
      "deals_stage_check",
      sql`${t.stage} in ('lead','qualified','won','lost')`,
    ),
  ],
);

/** Append-only interaction trail against a deal, logged by a Better Auth user. */
export const activities = mysqlTable(
  "activities",
  {
    id: varchar("id", { length: 36 }).primaryKey(),
    dealId: varchar("deal_id", { length: 36 })
      .notNull()
      .references(() => deals.id, { onDelete: "cascade" }),
    // Who logged the interaction.
    actorId: varchar("actor_id", { length: 255 })
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    type: varchar("type", { length: 32 }).$type<ActivityType>().notNull().default("note"),
    body: text("body"),
    occurredAt: timestamp("occurred_at").notNull().defaultNow(),
  },
  (t) => [
    // Drives the per-deal timeline (most-recent-first).
    index("idx_activity_deal_time").on(t.dealId, t.occurredAt),
    check(
      "activities_type_check",
      sql`${t.type} in ('call','email','note')`,
    ),
  ],
);

export const companiesRelations = relations(companies, ({ many }) => ({
  contacts: many(contacts),
}));

export const contactsRelations = relations(contacts, ({ one, many }) => ({
  company: one(companies, {
    fields: [contacts.companyId],
    references: [companies.id],
  }),
  owner: one(user, { fields: [contacts.ownerId], references: [user.id] }),
  deals: many(deals),
}));

export const pipelinesRelations = relations(pipelines, ({ many }) => ({
  deals: many(deals),
}));

export const dealsRelations = relations(deals, ({ one, many }) => ({
  contact: one(contacts, {
    fields: [deals.contactId],
    references: [contacts.id],
  }),
  pipeline: one(pipelines, {
    fields: [deals.pipelineId],
    references: [pipelines.id],
  }),
  owner: one(user, { fields: [deals.ownerId], references: [user.id] }),
  activities: many(activities),
}));

export const activitiesRelations = relations(activities, ({ one }) => ({
  deal: one(deals, { fields: [activities.dealId], references: [deals.id] }),
  actor: one(user, { fields: [activities.actorId], 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

Client/server split: the DB client, Drizzle schema, records, and webhooks are server-side (server/). The `@/` alias is the client root (app/); server code reaches shared modules via Nuxt's `~~` rootDir alias (e.g. `~~/server/db/schema`).

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

Deal stage is text + CHECK ('lead','qualified','won','lost') on the deals table — new stages ship without an ALTER TYPE migration; the idx_deal_stage index drives the pipeline board's 'deals grouped by stage' query.

note

activities is append-only (no updates, no deletes cascaded from deal): the per-deal timeline is always a raw log, never a mutated summary, queried via idx_activity_deal_time (deal_id, occurred_at).

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.