Helpdesk / support on Nuxt 4, MySQL 8 and Clerk
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
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 via Drizzle ORM and the mysql2 driver.
Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.
Customer support desk — tickets with status/priority queues, threaded messages with internal notes, agent profiles, and seed-managed SLA policies.
Setup
bun add nuxt vue drizzle-orm mysql2 @clerk/nextjsDATABASE_URLMySQL connection string (mysql://…)NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEYCLERK_WEBHOOK_SECRETsvix secret that verifies Clerk webhook signaturesApply the schema with bunx drizzle-kit push
Initialization
Database client
Helpdesk schema: tickets, messages, agents & SLA policies
Tickets & status/priority queuethe core support request with open/pending/solved/closed status and low/normal/high/urgent priority, indexed for queue views
Ticket messages & internal notesappend-only thread rows on each ticket; is_internal hides agent-only notes from the requester
Agents & team assignmentone agent profile per Better Auth user (unique on user_id), with an optional team field for queue segmentation
SLA policies per priorityseed-managed response and resolution targets in integer minutes, keyed by priority tier
Verified identity sync (Clerk)
Deploy targets
Decisions and compatibility
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`).
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.
Hosted: Clerk owns identity and does NOT create a local `user` table. Store `clerk_user_id` as text without a foreign key, or sync Clerk users into a local table via webhook before relying on FKs to `user`.
Status and priority are text + CHECK (not pgEnum), so adding a new value (e.g. 'escalated') ships without an ALTER TYPE migration — consistent with the house style in packages/db/src/schema.ts.
agents carries a unique constraint on user_id (one profile per user) and sla_policies carries no unique on priority, allowing multiple named policies at the same priority tier for different customer tiers.
Clerk is a hosted identity provider and does not create a local `user` table. This schema's foreign keys to `user` assume a local identity table (as Better Auth provides). With Clerk, store `clerk_user_id` as a text column without a foreign key, or sync Clerk users into a local `users` table via webhook before relying on these FKs.
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.