Skip to content

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.

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.

Auth collections merge with user-defined collections following these rules:

  1. Protected collections — auth collections marked as protected cannot be overridden by user-defined collections with the same slug. Attempting to do so throws a VexAuthConfigError.
  2. Locked fields — fields on auth collections with meta.locked: true are preserved during merge. User-defined fields with the same key are ignored.
  3. Unlocked fields — user-defined fields override or extend auth fields.
  4. User-only collections — user-defined collections with no matching auth collection are added as-is.
  5. Auth-only collections — auth collections with no matching user collection are appended at the end.

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.

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 bundle
import 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.

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],
});

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

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 client
config's authCollections call to match your auth options.

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:

src/auth/schema.ts
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;

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.

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.

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 unchanged
import { 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.

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.