Authentication
VexCMS supports authentication via auth adapters. An auth adapter introspects your auth provider’s schema and converts it into standard Vex collections that are merged with your user-defined collections.
How it works
Section titled “How it works”Auth adapters implement the VexAuthAdapter interface. They expose:
name— provider identifier (e.g."better-auth")collections— auth collections (user, session, account, verification, etc.)userCollection— the slug of the collection that stores user documents
VexCMS configuration is split into a client-safe vex.config.ts and a server-only
vex.config.server.ts — the same split the Upload field guide
documents for storage adapters. A live auth adapter instance closes over your real plugin
factories (secrets, mailers, database calls), so it is registered only on the server
config, via defineServerConfig({ server: { auth: { adapter } } }) — never on
defineConfig(). The client config still needs the collections that adapter would
produce, so it builds them itself from a plain-data description of your auth setup, through
the auth package’s own client-safe builder — for Better Auth, betterAuthCollections() from
@vexcms/better-auth/client. defineConfig() merges the result (authCollections) with your
user-defined collections via mergeAuthCollections.
Merge behavior
Section titled “Merge behavior”Auth collections merge with user-defined collections following these rules:
- Protected collections — auth collections marked as
protectedcannot be overridden by user-defined collections with the same slug. Attempting to do so throws aVexAuthConfigError. - Locked fields — fields on auth collections with
meta.locked: trueare preserved during merge. User-defined fields with the same key are ignored. - Unlocked fields — user-defined fields override or extend auth fields.
- User-only collections — user-defined collections with no matching auth collection are added as-is.
- Auth-only collections — auth collections with no matching user collection are appended at the end.
Better Auth
Section titled “Better Auth”The @vexcms/better-auth package provides a betterAuthAdapter() that
introspects a Better Auth configuration and
converts its tables into Vex collections, plus a client-safe
betterAuthCollections() that derives the same collections from plain data
— no plugin factories, no environment reads, no server SDKs.
The client-safe schema module
Section titled “The client-safe schema module”Author a small module — conventionally src/auth/schema.ts — holding everything about your
Better Auth setup that determines which collections exist and which fields they carry: model
names, user.additionalFields, and which plugins are registered. Nothing else belongs in it:
// src/auth/schema.ts — no server imports, safe in the browser bundleimport type { BetterAuthSchemaInput } from "@vexcms/better-auth/client";
export const authSchema = { user: { additionalFields: { roles: { type: "string[]", defaultValue: ["user"], required: true }, }, }, plugins: { admin: true, anonymous: true, apiKey: true, convex: true, organization: { teams: true }, },} satisfies BetterAuthSchemaInput;This module is deliberately separate from your real Better Auth options (conventionally
src/auth/options.ts): that file reads process.env for the secret and base URL, and its
plugin list typically pulls in better-auth/next-js — which imports next/headers. Importing
any binding from options.ts would evaluate all of that, so the client-safe half lives on its
own and options.ts spreads it instead.
Building the auth collections
Section titled “Building the auth collections”vex.config.ts calls betterAuthCollections() directly — it runs Better Auth’s own
getAuthTables() over a committed schema snapshot (no plugin factories) and converts the
result with the same pipeline betterAuthAdapter() uses server-side, so the two are identical
by construction:
// vex.config.ts (client)import { betterAuthCollections } from "@vexcms/better-auth/client";import { defineConfig } from "@vexcms/core";
import { authSchema } from "./auth/schema";
export default defineConfig({ authCollections: betterAuthCollections(authSchema), collections: [posts, authors],});Spreading into the real options
Section titled “Spreading into the real options”authSchema.plugins is a descriptor map for betterAuthCollections(), not Better Auth plugin
instances, so options.ts drops that key by name and supplies the real plugin factories —
along with the secret, base URL, and anything else server-only — instead:
// src/auth/options.ts (server)import type { BetterAuthOptions } from "better-auth";
import { createPlugins } from "./plugins";import { authSchema } from "./schema";
const { plugins: _pluginDescriptors, ...schemaOptions } = authSchema;
export const authOptions: BetterAuthOptions = { ...schemaOptions, baseURL: process.env.SITE_URL, secret: process.env.BETTER_AUTH_SECRET, plugins: createPlugins(),};Model names and user.additionalFields now have one source of truth — a field added to
authSchema shows up in vex.config.ts’s collections and in authOptions’s real schema
without being written twice. The plugin list is necessarily stated twice: authSchema.plugins
names which plugins affect the schema, createPlugins() builds the real instances with their
runtime options (admin roles, invitation mailers, OAuth secrets).
// vex.config.server.ts (server)import { betterAuthAdapter } from "@vexcms/better-auth";import { defineServerConfig } from "@vexcms/core";
import { authOptions } from "./auth/options";
import vexConfig from "./vex.config";
export default defineServerConfig({ config: vexConfig, server: { auth: { adapter: betterAuthAdapter({ config: authOptions }) }, },});Verifying the two agree
Section titled “Verifying the two agree”server.auth is optional — omit it for a project with no auth, or to skip verification;
authCollections declared on the client config are still registered either way. When it’s
present, defineServerConfig compares config.authCollections (built from authSchema, at
client-config eval time) against the live adapter’s collections (built from the real
authOptions), and throws VexAuthConfigError naming the first divergence the moment
authSchema and authOptions fall out of sync — e.g. a field added to a plugin’s real
options but not to authSchema:
VexAuthConfigError: The auth collections declared in the client config disagree with the"better-auth" adapter: collection "user" is missing field(s) "phone". Update the clientconfig's authCollections call to match your auth options.Modelled plugins and extraSchema
Section titled “Modelled plugins and extraSchema”betterAuthCollections() models 11 plugins’ schemas directly: admin, anonymous, apiKey,
convex, deviceAuthorization, jwt, mcp, oidcProvider, organization (teams is the
one option that changes which tables exist), phoneNumber, siwe, and twoFactor. Each
descriptor accepts true or schema-affecting options only ({ schema: { ... } } to rename
columns or add fields) — runtime options like adminRoles or invitation mailers are rejected
here by design, since accepting them would let a server callback end up in the browser bundle;
they stay on the real plugin instances createPlugins() builds.
Naming any other better-auth/plugins export in authSchema.plugins is a type error
pointing at the fix: read that plugin’s schema off its real instance on the server, and pass
the plain-data value through extraSchema instead — hand-copied into the client-safe module,
never imported from better-auth/plugins:
export const authSchema = { plugins: { admin: true }, // `magicLink` isn't modelled — copy its `schema` shape from the real plugin // instance (`./plugins.ts`) rather than importing `better-auth/plugins` here. extraSchema: { verification: { fields: {}, }, },} satisfies BetterAuthSchemaInput;Keeping the plugin schema snapshot fresh
Section titled “Keeping the plugin schema snapshot fresh”betterAuthCollections() reads plugin schemas from a snapshot committed to
@vexcms/better-auth (pluginSchemas.generated.ts), so it never has to instantiate a real
plugin factory. Projects never run codegen for this — regenerating the snapshot after a Better
Auth upgrade is @vexcms/better-auth’s own maintenance task:
pnpm --filter @vexcms/better-auth gen:auth-schemas. A parity test
(pluginSchemas.test.ts) fails whenever the committed snapshot and the installed better-auth
version disagree, and a new upstream plugin fails the package’s typecheck via an exhaustiveness
assertion until it is classified as modelled or explicitly left to extraSchema.
Field mapping
Section titled “Field mapping”betterAuthAdapter() maps Better Auth DBFieldAttribute types to Vex field
builders:
| Better Auth type | Vex field |
|------------------|-----------|
| string | text() |
| boolean | checkbox() |
| number | number() |
| date, timestamp | date() |
| json | text() (fallback) |
| string[] (enum) | select() |
| references | relationship() |
System fields are automatically marked as admin.readOnly and meta.locked.
Sensitive fields (hashedPassword, twoFactorSecret, etc.) are hidden from
the admin UI with admin.hidden: true.
Extending the user collection
Section titled “Extending the user collection”The user collection is not protected, so you can extend it with user-defined fields:
// vex.config.ts (client) — the server config from above is unchangedimport { betterAuthCollections } from "@vexcms/better-auth/client";import { defineConfig, defineCollection, checkbox, text } from "@vexcms/core";
import { authSchema } from "./auth/schema";
const user = defineCollection({ slug: "user", fields: { bio: text(), publicProfile: checkbox({ defaultValue: true }), },});
export default defineConfig({ authCollections: betterAuthCollections(authSchema), collections: [user, posts],});Locked fields (email, emailVerified, createdAt, etc.) are preserved. Your
unlocked fields (bio, publicProfile) are merged in.
Admin UI behavior
Section titled “Admin UI behavior”Auth collections appear in the admin sidebar alongside user-defined collections. System fields are read-only and cannot be edited. User-editable fields (name, email, image, role, etc.) can be modified through the standard document edit form.