codexmachina
registry/nuxt-postgres-supabase-ai-wrapper-resend

AI Wrapper on Nuxt 4, Postgres (Neon) and Supabase Auth with Resend

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

79 files, 3 tables and 121 lines of schema, verified 2026-08-23 on Nuxt 4, Postgres (Neon) and Supabase Auth.

Download .tar.gzverified 2026-08-23How we verify
Nuxt 4 chat starter: the chat surface, rendered from the verified UI
Rendered from the verified starter · the chat surface
16 pinned upstream versions
clsx2.1.1nuxt4.5.2vaul1.1.2resend6.18.1shadcn4.16.1sonner2.0.7postgres3.4.9radix-ui1.6.7recharts3.10.1lucide-react1.28.0@supabase/ssr0.12.4tailwind-merge3.6.0tw-animate-css1.4.0@tanstack/react-table8.21.3@neondatabase/serverless1.1.0class-variance-authority0.7.1
Browserrequest
fetch
Nuxt 4routing + proxy
verify
Supabase Authsession
query
Postgres (Neon)pooled

request path. 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.

Postgres (Neon)

Postgres on Neon via Drizzle ORM and the postgres-js driver.

Supabase Auth

Supabase Auth: hosted identity (GoTrue) via the @supabase/ssr cookie client; email+password + OAuth.

AI Wrapper

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

Resend

Resend: transactional email (welcome/notification always; verify/reset when auth is self-hosted).

Setup

bun add nuxt vue drizzle-orm postgres @supabase/ssr @supabase/supabase-js
DATABASE_URLNeon pooled (-pooler) connection string
NEXT_PUBLIC_SUPABASE_URLNext: your Supabase project URL (RR: VITE_SUPABASE_URL · Nuxt: NUXT_PUBLIC_SUPABASE_URL)
NEXT_PUBLIC_SUPABASE_ANON_KEYNext: the project's anon/public key (RR: VITE_SUPABASE_ANON_KEY · Nuxt: NUXT_PUBLIC_SUPABASE_ANON_KEY)
SUPABASE_URLReact Router server-side (loaders): same project URL, read off process.env — never inlined into the client bundle
SUPABASE_ANON_KEYReact Router server-side: same anon key

Apply the schema with bunx drizzle-kit push

Initialization

Database client

server/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Neon pooled endpoint = PgBouncer transaction mode → prepared statements off.
// ponytail: single module-level client; the serverless runtime + PgBouncer do
// the pooling, so no custom pool/globalThis singleton dance needed.
const client = postgres(process.env.DATABASE_URL!, { prepare: false });

export const db = drizzle({ client });

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

3 tables, 24 columns and 8 indexes and constraints, applied to a live Postgres (Neon) and asserted to materialize.

user_profiles6 columns · 3 indexed
token_usage7 columns · 3 indexed
prompt_logs11 columns · 2 indexed

What this schema is built to answer

Tokens and cost a user has burned this calendar month

A range scan on token_usage through idx_usage_user_time (user_id, created_at), summing prompt_tokens plus completion_tokens and cost_microcents. user_id is the leading column, so the month boundary costs nothing beyond the range itself.

Refusing a call that would blow the monthly budget

One probe of user_profiles on its UNIQUE user_id — the 1:1 foreign key into user — to read monthly_token_budget, compared against the idx_usage_user_time rollup. The bigint is a soft cap the dispatch path checks; no database constraint backs it.

Cost attribution per model across every user

idx_usage_model on token_usage.model groups spend by provider model id without touching user_profiles at all, and cost_microcents being an integer means the sum stays exact however many sub-cent rows it covers.

The most recent exchanges in a user's history pane

prompt_logs read back along idx_log_user_time (user_id, created_at), newest first. Prompt, completion, latency_ms and the JSON metadata all live on the row, so the feed renders from one index range with no join to token_usage.

Error and filtered-call rate for one user

status is held to three values by prompt_logs_status_check but carries no index of its own, so count it inside a bounded idx_log_user_time range rather than scanning prompt_logs by status across all users.

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: server/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  bigint,
  check,
  index,
  integer,
  jsonb,
  pgTable,
  text,
  timestamp,
  uuid,
} from "drizzle-orm/pg-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 = pgTable(
  "user_profiles",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    // Better Auth's user.id is text — match it, don't recast. One profile per
    // user, so the FK itself is unique (this is the 1:1 extension of `user`).
    userId: text("user_id")
      .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: text("default_model").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", { withTimezone: true })
      .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 = pgTable(
  "token_usage",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    // Which upstream model was billed (provider model id, opaque to us).
    model: text("model").notNull(),
    promptTokens: integer("prompt_tokens").notNull().default(0),
    completionTokens: integer("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", { withTimezone: true })
      .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 = pgTable(
  "prompt_logs",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    model: text("model").notNull(),
    prompt: text("prompt").notNull(),
    // Null while a streamed completion is still in flight.
    completion: text("completion"),
    promptTokens: integer("prompt_tokens").notNull().default(0),
    completionTokens: integer("completion_tokens").notNull().default(0),
    latencyMs: integer("latency_ms"),
    status: text("status").$type<CallStatus>().notNull().default("ok"),
    // ponytail: opaque per-call metadata (tool calls, finish_reason, etc.) —
    // jsonb bag beats a column-per-provider-field churn.
    metadata: jsonb("metadata"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .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/postgres-js";
import postgres from "postgres";

// Serverless: one connection per (short-lived) instance; Neon's pooler multiplexes.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 1 });
export const db = drizzle(sql);

The app UI

A working auth flow and a protected app shell, type-checked against the same verified wiring above. This is what codexmachina create scaffolds on top of the official Nuxt 4 starter.
app/components/nav-user.vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue"
import {
  EllipsisVerticalIcon,
  CircleUserRoundIcon,
  CreditCardIcon,
  BellIcon,
  LogOutIcon,
} from "lucide-vue-next"
import type { User } from "@supabase/supabase-js"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
import { createClient } from "~~/lib/supabase-client"

// Supabase variant of the sidebar-footer user menu — the SAME shell every auth overlay reuses, wired
// to the browser Supabase client: getUser() on mount + onAuthStateChange to stay reactive across tabs,
// cookie-based sign-out.
const { isMobile } = useSidebar()
const supabase = createClient()

const user = ref<User | null>(null)
const loading = ref(true)
let unsubscribe: (() => void) | undefined

onMounted(async () => {
  const { data } = await supabase.auth.getUser()
  user.value = data.user
  loading.value = false
  const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
    user.value = session?.user ?? null
  })
  unsubscribe = () => listener.subscription.unsubscribe()
})
onBeforeUnmount(() => unsubscribe?.())

const email = computed(() => user.value?.email ?? "")
const initials = computed(() => email.value.slice(0, 2).toUpperCase())
const avatarUrl = computed(() => {
  const url = user.value?.user_metadata?.avatar_url
  return typeof url === "string" ? url : undefined
})

async function handleSignOut() {
  await supabase.auth.signOut()
  navigateTo("/sign-in")
}
</script>
<template>
  <SidebarMenu>
    <SidebarMenuItem>
      <div v-if="loading" class="flex items-center gap-2 p-2">
        <Skeleton class="size-8 rounded-lg" />
        <Skeleton class="h-4 w-28 rounded-md" />
      </div>
      <DropdownMenu v-else-if="user">
        <DropdownMenuTrigger as-child>
          <SidebarMenuButton
            size="lg"
            class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
          >
            <Avatar class="size-8 rounded-lg grayscale">
              <AvatarImage v-if="avatarUrl" :src="avatarUrl" :alt="email" />
              <AvatarFallback class="rounded-lg">{{ initials }}</AvatarFallback>
            </Avatar>
            <div class="grid flex-1 text-left text-sm leading-tight">
              <span class="truncate font-medium">{{ email }}</span>
            </div>
            <EllipsisVerticalIcon class="ml-auto size-4" />
          </SidebarMenuButton>
        </DropdownMenuTrigger>
        <DropdownMenuContent
          class="min-w-56 rounded-lg"
          :side="isMobile ? 'bottom' : 'right'"
          align="end"
          :side-offset="4"
        >
          <DropdownMenuLabel class="p-0 font-normal">
            <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
              <Avatar class="size-8 rounded-lg">
                <AvatarImage v-if="avatarUrl" :src="avatarUrl" :alt="email" />
                <AvatarFallback class="rounded-lg">{{ initials }}</AvatarFallback>
              </Avatar>
              <div class="grid flex-1 text-left text-sm leading-tight">
                <span class="truncate font-medium">{{ email }}</span>
              </div>
            </div>
          </DropdownMenuLabel>
          <DropdownMenuSeparator />
          <DropdownMenuGroup>
            <DropdownMenuItem>
              <CircleUserRoundIcon />
              Account
            </DropdownMenuItem>
            <DropdownMenuItem>
              <CreditCardIcon />
              Billing
            </DropdownMenuItem>
            <DropdownMenuItem>
              <BellIcon />
              Notifications
            </DropdownMenuItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuItem @click="handleSignOut">
            <LogOutIcon />
            Log out
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </SidebarMenuItem>
  </SidebarMenu>
</template>

Email — verified (Resend)

✓ Audited with emailens — above our disclosed compatibility threshold across 21 email clients. Every template ships an unsubscribe link, a physical mailing address, and explicit lang/charset/contrast — the concrete set the audit checks for.
emails/welcome.mjml
<mjml lang="en">
  <mj-head>
    <mj-title>Welcome aboard</mj-title>
    <mj-attributes>
      <mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" />
    </mj-attributes>
  </mj-head>
  <mj-body background-color="#f4f4f5">
    <mj-raw><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0;">You're in — here's how to get started.</div></mj-raw>
    <mj-section background-color="#ffffff" padding="32px" border-radius="8px">
      <mj-column>
        <mj-text color="#111827" font-size="22px" font-weight="700">Welcome, {{name}}!</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">Thanks for signing up. Your account is ready to go.</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">If you have any questions, just reply to this email — a real person reads it.</mj-text>
        <mj-raw>
          <table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
            <tr>
              <td style="border-radius:6px;background-color:#111827;" align="center">
                <a href="{{ctaUrl}}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;background-color:#111827;border-radius:6px;text-decoration:none;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">Go to your dashboard</a>
              </td>
            </tr>
          </table>
        </mj-raw>
        <mj-divider border-color="#e5e7eb" />
        <mj-text color="#4b5563" font-size="12px" line-height="18px">Acme Inc, 548 Market St PMB 12345, San Francisco, CA 94104</mj-text>
        <mj-text color="#4b5563" font-size="12px" line-height="18px"><a href="{{unsubscribeUrl}}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a></mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

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

The API layer is Nitro, Nuxt's server engine: endpoints are server/api/*.post.ts route handlers, and auth mounts as a Nitro catch-all that delegates to the auth library's framework-agnostic web handler.

note

Nuxt auto-imports components and composables at runtime, but the emitted server code imports h3 helpers (defineEventHandler, toWebRequest) EXPLICITLY — the one deliberate idiom trade so the handlers type-check under standalone tsc instead of relying on the auto-import magic.

note

Session gating runs in a Nitro server middleware (server/middleware/), which fires on every SSR and API request — the true security boundary, and a real server-side session check rather than a cookie-existence peek.

note

prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.

note

Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.

note

Hosted: Supabase owns identity in its managed auth.users. This stack emits a LOCAL `user` mirror (db/auth-schema.ts) so app-type schemas can foreign-key `user` directly — keep it in sync with a Supabase trigger on auth.users (insert/update → public.user). The drizzle migration only owns the mirror table's shape, not the trigger.

note

Sessions are cookie-based (@supabase/ssr): the proxy refreshes them on every request; Server Components read the user via supabase.auth.getUser().

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.

How this stack fits together

On Nuxt 4 this stack puts its Postgres (Neon) client at server/lib/db.ts and the Supabase Auth instance at app/middleware/auth.ts and session checks in server/middleware/supabase.ts. Those are the paths this framework's adapter actually emits, not a shared convention: the same AI Wrapper schema and the same Supabase Auth wiring land somewhere different on each of the other frameworks in the registry.

Supabase Auth owns its identity tables in the same database as user_profiles, token_usage and prompt_logs, so the foreign keys reference the local user row directly and a delete cascades through them. No mirror, no webhook, and no window where the two stores disagree.

Postgres (Neon) stores those surrogate keys as uuid, so every foreign key across the 3 tables and 24 columns below is a uuid column. The migration was applied to a live Postgres (Neon) and the tables asserted, not just type-checked.

Resend is additive here: it does not change the AI Wrapper tables, it adds send helpers and templates that Supabase Auth calls for its verification and reset flows. The base cell without Resend is the same schema with those files removed.

AI Wrapper

Three tables sit on top of the identity Better Auth owns, and the line between them is settings on one side, records of calls that already happened on the other. user_profiles is the 1:1 extension of the identity row: user_id is both a foreign key into user and a UNIQUE constraint, which is what makes it an extension rather than a collection, and it holds the two values the wrapper reads at call time — default_model (text, defaulting to gpt-4o-mini) and monthly_token_budget (bigint). token_usage takes one row per completed provider call: the model billed, the prompt/completion token split, and cost_microcents as a bigint, because a float accumulating over millions of sub-cent rows is how rounding error ends up in an invoice.

prompt_logs takes one row per exchange — prompt text, completion (nullable, so a streamed response can be logged before it finishes), latency_ms, a status narrowed by prompt_logs_status_check to 'ok', 'error' or 'filtered', and a JSON metadata bag that absorbs finish_reason, tool calls, and whatever else a provider invents, without forcing a column migration each time. The two append-only streams are deliberately not wired to each other. Both reference user.id directly and no key connects a usage row to the log row for the same call, so metering and logging fail independently on the hot path — the cost of that is you cannot ask what one specific prompt cost without matching on user and timestamp yourself.

What the streams do share is their index: idx_usage_user_time and idx_log_user_time are the same (user_id, created_at) composite, so every cheap question here has the shape of this user, this window. Fleet-wide reads get exactly two handles — idx_usage_model on the billed model and idx_profile_model on the profile default — and nothing else. There is no index on status, so a failure-rate query is a filter applied inside a user-and-time range. The budget is a number in a column rather than a constraint: nothing in the database refuses an over-budget call, so enforcement is a profile read plus a range sum you run before dispatch.

Nuxt 4

Nuxt 4 in framework mode is the one stack here with two roots. Client code lives under app/ and is what `@/` points at (Vite, Vue single-file components); server code lives under server/ and is run by Nitro, Nuxt's server engine. The database layer is server-side, so initCode writes server/lib/db.ts and the schema, record modules and webhooks land under server/db/ and server/api/ — server modules reach each other through Nuxt's `~~` rootDir alias (`~~/server/lib/db`, `~~/server/db/schema`), never through `@/`. That split earns its keep with secrets: the Resend send client belongs to ~~/server/lib/email, and nothing under app/ can import it by accident. The API layer is Nitro rather than a React-shaped route file.

server/api/webhooks/polar.post.ts is a POST endpoint; auth mounts as the catch-all server/api/auth/[...all].ts, which adapts the H3 event with toWebRequest(event) and hands the resulting web Request to the auth library's framework-agnostic handler. Nuxt auto-imports defineEventHandler and its siblings at runtime, but the emitted server files import them from h3 explicitly — one deliberate idiom trade so every handler type-checks under standalone tsc. Session gating is a Nitro server middleware at server/middleware/auth.ts. It fires on every SSR render and every API request, filters on pathname prefixes (/dashboard, /settings), performs the real auth.api.getSession() lookup, and answers with sendRedirect(event, "/sign-in", 302). Because Nitro sits in front of both the rendered page and the endpoints, that is a genuine security boundary rather than a cheap pre-render bounce.

On the client, Vue does its own thing: the auth binding exposes signIn/signUp/useSession as Vue refs, screens are .vue components under app/pages/ (sign-in.vue, dashboard/[id].vue), chrome lives in app/components/ and app/layouts/, and SPA-side guards are app/middleware/*.ts. The design system is shadcn-vue on reka-ui — a real re-port, not the React components wearing new names — and it is checked with vue-tsc, since plain tsc cannot parse an SFC.

Postgres (Neon)

Postgres here is Neon reached through postgres-js, with Drizzle's pg-core dialect on top: drizzle({ client }) over a single module-level postgres(DATABASE_URL, { prepare: false }). That flag is not a preference. Neon's pooled (-pooler) endpoint is PgBouncer in transaction mode, where a backend is handed to a different session between statements, so server-side prepared statements break across the pool — and the same constraint is why this axis pairs with Drizzle rather than Prisma. One client per module is enough: PgBouncer and the runtime do the pooling, so there is no globalThis singleton dance. The schemas built on this dialect make three recurring type decisions. Primary keys are uuid(...).primaryKey().defaultRandom(), so ids come from the database. Timestamps are timestamp(..., { withTimezone: true }).defaultNow() — timestamptz, an absolute instant.

Closed value sets are text plus a CHECK constraint rather than pgEnum, so shipping a new role or subscription status is an ordinary constraint change instead of an ALTER TYPE migration. Counters are bigint({ mode: "number" }), and Better Auth's text user.id is referenced as text by the app tables rather than recast. Operationally, transaction-mode pooling forbids anything that spans statements on one backend: LISTEN/NOTIFY, session-scoped SET, advisory-lock sessions, WITH HOLD cursors. Those paths use Neon's direct endpoint instead. The connection client also changes with the deploy target — max: 1 per short-lived serverless instance, a real reused pool (max 10, idle_timeout 20) in a long-running Node process, and on Cloudflare Workers postgres-js is replaced outright by @neondatabase/serverless over HTTP, because Workers have no TCP sockets.

The capability that exists only on this side of the matrix is row-level security. Multi-tenant schemas ship ENABLE plus FORCE ROW LEVEL SECURITY with policies keyed on current_setting('app.current_org_id', true), which withTenant() sets per transaction — unset context yields no rows, so isolation fails closed inside the database rather than in application code. It requires a dedicated NOBYPASSRLS role: Neon's default neondb_owner carries BYPASSRLS, and connecting as it makes every policy silently inert.

Supabase Auth

Supabase Auth is a hosted service — GoTrue — that your app reaches over cookies rather than an SDK session object. Credentials and the canonical user records live in Supabase's managed auth.users schema. What lands in your own database is a mirror: db/auth-schema.ts declares user keyed by the Supabase auth uid (text; varchar(255) on MySQL) with email, full name, avatar URL and timestamps, so app-type schemas can foreign-key user exactly as they would under a self-hosted auth. Unlike the Clerk fragment, no sync webhook is emitted here, because Supabase's own trigger mechanism is the intended path: a trigger on auth.users writing into public.user lives in the Supabase project, not in the drizzle migration.

The migration owns the mirror's column shape and nothing else, so wiring that trigger is a step you take before those foreign keys mean anything. The mechanic that actually shapes this adapter is cookie refresh. @supabase/ssr rotates the auth token, and a rotated cookie only reaches the browser if something writes it onto the outgoing response — which is why every framework branch is built around the same getAll/setAll pair, wired to whatever that framework calls a cookie jar. Next's src/proxy.ts rebuilds the NextResponse inside setAll before calling getUser().

React Router has no middleware layer, so app/lib/supabase/server.ts constructs the client per request and returns { supabase, headers }, and the protected layout route attaches those headers to both exits — the redirect and the pass-through — so a refresh that happened during a guard is not lost. Nuxt's server/utils/supabase.ts binds the client to the h3 event and writes through setCookie, with a Nitro middleware doing the guard. Every decision point calls supabase.auth.getUser(), never getSession(): getSession reads whatever the cookie claims, getUser revalidates it against Supabase. Because a browser client is emitted alongside the server one, the auth screens are real forms calling supabase.auth.signInWithPassword rather than a hosted widget, and the sidebar's user menu subscribes to onAuthStateChange. Two consequences to plan around.

The anon key is public by design and is inlined into the client bundle under whichever prefix the framework demands (NEXT_PUBLIC_, VITE_, NUXT_PUBLIC_), so protection has to come from row-level security on Supabase's side, not from keeping the key quiet. And every guard is a network call to Supabase, not a local query — cheap, but not free, and on the path of every protected request.

Resend

Resend covers two jobs: the transactional templates and the module that sends them. `emailTemplates` branches on `framework.slug` — Next.js and React Router get React Email components under `emails/` (`welcome.tsx`, `notification.tsx`, plus `verify-email.tsx` and `reset-password.tsx` when the auth is library-kind), Nuxt gets the same four as `.mjml` files. Both serializations read subject, preview, heading, body lines and CTA label from the shared `EMAIL_COPY` map, so the words are identical and only the markup differs. Every template closes with the `BRAND_ADDRESS` mailing address and an Unsubscribe link, and the React path sets `lang="en"`, a non-empty `<title>` and an explicit `<meta charSet="utf-8">`. `emailSend` emits `lib/email.ts`.

The provider client is built lazily inside `resendClient()` (`client ??= new Resend(process.env.RESEND_API_KEY)`) because `new Resend(undefined)` throws "Missing API key" — at module scope that throw fires at import time, which is exactly when a framework build imports this file to collect page data, so `next build` would fail on any machine holding only runtime secrets. `FROM` reads `EMAIL_FROM` and falls back to Resend's sandbox sender. On Next and React Router each sender awaits `render(WelcomeEmail(props))` from `@react-email/render` — async in the installed 2.1.0 — then calls `resendClient().emails.send({ from, to, subject, html })`.

On Nuxt the templates are inlined as string constants instead, `fill()` substitutes their `{{token}}` placeholders, `mjml2html(src, { validationLevel: "soft" })` compiles them and throws on any returned `errors`, and an ambient `mjml.d.ts` ships alongside because mjml@5 carries no type declarations. Which senders exist is gated on `auth.kind`. Hosted auth gets `sendWelcome` and `sendNotification` only. A library auth additionally gets `sendVerifyEmail` and `sendResetPassword` — the two functions its own config imports back out of this module to fill Better Auth's `emailAndPassword.sendResetPassword` and `emailVerification.sendVerificationEmail` hooks. A hosted provider sends those mails from its own infrastructure.