Configuration

Convex Component

Overview

Vextro ships a Convex component that owns the admin metadata tables: adminCollections, adminGlobals, adminFields, adminRelationships, adminUserPreferences, adminDocumentVersions, adminSavedViews, and vextro_files. Your app mounts the component in convex.config.ts and creates a thin admin module that wraps the component's internal functions with your auth logic.

Installation

pnpm add vextro

If you use rich text editing with TipTap, also install the Prosemirror sync component:

pnpm add @convex-dev/prosemirror-sync

Mounting the component

Register Vextro (and optionally Prosemirror sync) in your Convex app config.

// convex/convex.config.ts
import { defineApp } from "convex/server";
import vextro from "vextro/convex.config";
import prosemirrorSync from "@convex-dev/prosemirror-sync/convex.config.js";

const app = defineApp();

app.use(vextro);
app.use(prosemirrorSync);

export default app;

Component isolation

The Vextro component uses its own internal tables, separate from your app's schema. You never need to define adminCollections or adminFields in your own schema.ts. The component manages these tables automatically.

Creating the admin module

The admin module is the bridge between the Vextro component and your app's auth system. It wraps every component query and mutation with permission checks.

Minimal setup

Only three fields are truly required: query, mutation, and components. Everything else has sensible defaults.

// convex/admin.ts
import { query, mutation } from "./_generated/server";
import { components } from "./_generated/api";
import { createVextroAdminModule, buildAdminDefinitions } from "vextro/convex/admin";
import { posts, pages, media } from "./collections";
import { cta, hero } from "./blocks";

const collectionDefinitions = buildAdminDefinitions({
  collections: [posts, pages, media],
  blocks: [cta, hero],
});

const admin = createVextroAdminModule({
  query,
  mutation,
  components,
  collectionDefinitions,
});

export const {
  listCollections,
  getCollectionBySlug,
  listGlobals,
  getShellData,
  getGlobalBySlug,
  listFieldsForCollection,
  listFieldsForGlobal,
  listDocumentsForCollection,
  getCollectionPageData,
  paginateDocuments,
  getDocumentForCollection,
  createDocument,
  updateDocument,
  deleteDocument,
  seedAdminMetadata,
} = admin;

Built-in defaults

OptionDefault behaviorWhen to override
getCurrentUserQueries the users table using a by_tokenIdentifier index with the identity from ctx.auth.getUserIdentity(). Returns null when unauthenticated.Your user table has a different name, uses a different index, or you need to join additional data (roles, scopes).
requireAdminReadCalls ctx.auth.getUserIdentity() and throws "Not authenticated" if null.You need role-based or permission-based read guards.
requireAdminWriteSame as requireAdminRead — checks for any authenticated identity.You need stricter write guards (e.g., require an admin or editor role).
isTableNameTreats all strings as valid table names.You want runtime validation that a table name exists in your schema.
getSampleDocReads the first document from the collection's table via ctx.db.query(tableName).take(1).You maintain a static sample map or want to skip the DB query.
userProfileFields["displayName", "avatarUrl"]You expose different fields for user self-service profile edits.

Overriding defaults

Pass any callback to replace its default. For example, to add role-based guards:

const admin = createVextroAdminModule({
  query,
  mutation,
  components,
  collectionDefinitions,
  requireAdminRead: async (ctx) => {
    await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);
  },
  requireAdminWrite: async (ctx) => {
    await requirePermission(ctx, "cms:admin");
  },
});

Or to customize user lookup when your table uses a different index:

const admin = createVextroAdminModule({
  query,
  mutation,
  components,
  collectionDefinitions,
  getCurrentUser: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;
    return await ctx.db
      .query("users")
      .withIndex("by_clerkId", (q) =>
        q.eq("clerkId", identity.subject)
      )
      .unique();
  },
});

All arguments reference

PropertyTypeRequiredDescription
queryQueryBuilderYesConvex query builder from _generated/server
mutationMutationBuilderYesConvex mutation builder from _generated/server
componentsVextroAdminComponentRefsYesComponent references from _generated/api
collectionDefinitionsArray<CollectionDefinition>NoCollection definitions from buildAdminDefinitions()
globalDefinitionsArray<GlobalDefinition>NoGlobal definitions for singleton documents
isTableName(name: string) => name is TableNameNoType guard for runtime table name validation
getCurrentUser(ctx) => Promise<User | null>NoResolve the authenticated user for audit/presence
requireAdminRead(ctx) => Promise<void>NoAuth guard for read operations; throw to deny
requireAdminWrite(ctx) => Promise<void>NoAuth guard for write operations; throw to deny
getUserRoles(ctx) => Promise<string[]>NoReturn role slugs for the current user (RBAC)
rbacFailOpenbooleanNoIf true, role resolution errors silently bypass RBAC (default false)
accessControlAccessControlDefinitionsNoCode-defined roles, permissions, and table overrides
requireAccessControlRead(ctx) => Promise<void>NoAuth guard for role/user management queries
requireAccessControlWrite(ctx) => Promise<void>NoAuth guard for role/user management mutations
getUserScopes(ctx, scopeType) => Promise<string[] | null>NoReturn scope IDs the user can access; null = unrestricted
canAccessScope(ctx, scopeType, scopeValue) => Promise<boolean>NoValidate user access to a specific scope value
listScopeOptions(ctx, scopeType) => Promise<Array<{ id, label }>>NoProvide options for the scope selector UI
scheduleCacheInvalidation(ctx, args) => Promise<void>NoSchedule cache invalidation after document changes
scheduleWebhookDelivery(ctx, args) => Promise<void>NoSchedule webhook HTTP delivery as an action
userProfileFieldsstring[]NoFields users can edit on their own profile (default: ["displayName", "avatarUrl"])
onUserStatusChange(ctx, { userId, previousStatus, newStatus }) => Promise<void>NoHook called when a user's status changes
blockDeletionStrategy"preserve" | "delete-exclusive"NoHow to handle blocks when a parent document is deleted (default: "preserve")
richText{ toolbar?: RichTextToolbar }NoProject-level rich text editor defaults
getSampleDoc(ctx, definition) => Promise<Record | undefined>NoReturn a sample document for a collection
getCollectionDefinitions(ctx) => Promise<Array<CollectionDefinition>>NoDynamic collection definitions loader for mutations

For detailed descriptions, default behaviors, and a full production example, see the Admin Module LLM Reference.

Automatic metadata seeding

Vextro automatically keeps admin metadata in sync with your collection and global definitions. There is nothing to call manually.

How it works

Vextro computes a fingerprint of your collection and global definitions at module init time. On every admin page load, the shell compares this fingerprint against the stored metadata. When they diverge, Vextro automatically re-seeds — upserting collections, globals, and fields, archiving removed fields, and updating the stored fingerprint. This happens transparently before the page renders.

The seedAdminMetadata mutation is part of the admin module exports. Make sure it is included in your destructured exports so the shell can call it:

export const {
  listCollections,
  getCollectionBySlug,
  seedAdminMetadata, // Required — called automatically by the admin shell
  // ...other exports
} = admin;

The Schema Diagnostics page also provides a manual "Seed" button as a safety valve, but under normal operation you never need to use it.

RBAC schema tables

When using createVextroSchema, Vextro auto-generates six RBAC tables by default: users, roles, permissions, userRoles, rolePermissions, and userScopes. These tables are included in the tables output and spread into your Convex schema automatically.

import { createVextroSchema } from "vextro/convex/schema";
import { defineSchema } from "convex/server";

const vextro = createVextroSchema({
  collections: [pages, posts, media],
  globals: [siteSettings],
  // RBAC tables are generated by default — no config needed
});

export default defineSchema({
  ...vextro.tables, // includes users, roles, permissions, userRoles, rolePermissions, userScopes
});

Disabling RBAC tables

If you manage your own user/role tables, disable auto-generation:

const vextro = createVextroSchema({
  collections: [pages, posts],
  globals: [],
  accessControl: { enabled: false },
});

Overriding table names

If your project uses different table names (e.g., members instead of users), pass a tables override. Foreign key references (v.id(...)) in the join tables automatically use the overridden names.

const vextro = createVextroSchema({
  collections: [pages, posts],
  globals: [],
  accessControl: {
    tables: {
      users: "members",
      roles: "app_roles",
      permissions: "app_permissions",
      userRoles: "member_roles",
      rolePermissions: "app_role_permissions",
      userScopes: "member_scopes",
    },
  },
});

Extending the users table

Add custom fields and indexes to the auto-generated users table using userFields and userIndexes:

const vextro = createVextroSchema({
  collections: [pages, posts],
  globals: [],
  accessControl: {
    userFields: {
      phone: v.optional(v.string()),
      department: v.optional(v.string()),
    },
    userIndexes: [
      { name: "by_department", fields: ["department"] },
    ],
  },
});

Config reference

OptionTypeDefaultDescription
enabledbooleantrueSet to false to skip RBAC table generation
tablesobjectSee belowOverride default table names
userFieldsRecord<string, Validator>{}Additional fields merged into the users table
userIndexesArray<{ name, fields }>[]Additional indexes on the users table

Default table names: users, roles, permissions, userRoles, rolePermissions, userScopes.

RBAC tables are not admin collections

The auto-generated RBAC tables are added to the schema but do not appear in the admin sidebar. They are managed through the dedicated Roles, Users, and Access pages that Vextro provides when you configure accessControl on createVextroAdminModule.

Access control definition

Pass an accessControl config to createVextroAdminModule to enable built-in role and user management. This declares your permission model and tells Vextro which tables to use for RBAC queries.

const admin = createVextroAdminModule({
  // ...auth callbacks
  accessControl: {
    permissions: [
      { slug: "cms:read", description: "View CMS content" },
      { slug: "cms:write", description: "Create and edit content" },
      { slug: "cms:admin", description: "Full CMS admin access" },
    ],
    roles: [
      {
        slug: "admin",
        name: "Administrator",
        description: "Full system access",
        permissions: ["cms:read", "cms:write", "cms:admin"],
      },
      {
        slug: "editor",
        name: "Editor",
        description: "Content editing access",
        permissions: ["cms:read", "cms:write"],
      },
    ],
    tables: {
      roles: "roles",
      permissions: "permissions",
      userRoles: "userRoles",
      rolePermissions: "rolePermissions",
      users: "users",
      userScopes: "userScopes",
    },
  },
  requireAccessControlRead: async (ctx) => {
    // Optional: separate auth for role/user management pages
    // Falls back to requireAdminRead if not provided
  },
  requireAccessControlWrite: async (ctx) => {
    // Optional: separate auth for role/user management mutations
    // Falls back to requireAdminWrite if not provided
  },
  userProfileFields: ["displayName", "avatarUrl"],
});

When accessControl is provided, Vextro adds 23 functions to the admin module for managing roles, users, and scopes. See Access Control for the full list.

OptionTypeDescription
permissionsArray<{ slug, description }>Permission definitions to sync to the database
rolesArray<{ slug, name, description, permissions }>Role definitions with default permission assignments
tablesobjectTable name overrides (see below)
requireAccessControlRead(ctx) => Promise<void>Auth guard for role/user queries. Falls back to requireAdminRead.
requireAccessControlWrite(ctx) => Promise<void>Auth guard for role/user mutations. Falls back to requireAdminWrite.
userProfileFieldsstring[]Fields users can edit on their own profile. Default: ["displayName", "avatarUrl"].

Table name overrides

KeyDefaultDescription
roles"roles"Roles table
permissions"permissions"Permissions table
userRoles"userRoles"User-role join table
rolePermissions"rolePermissions"Role-permission join table
users"users"Users table
userScopes"userScopes"User-scope assignments table

Admin actions factory

Vextro provides a factory for creating admin internal actions — image processing, orphaned file cleanup, and webhook delivery. Only internalAction and components are required.

Minimal setup

// convex/adminActions.ts
"use node";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { createVextroAdminActions } from "vextro/convex/adminActions";

const actions = createVextroAdminActions({ internalAction, components });

export const { processUploadedImage, cleanupOrphanedFiles, deliverWebhook } = actions;

S3 credentials are read automatically from environment variables: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, plus optional S3_ENDPOINT, S3_PUBLIC_URL_BASE, S3_ACL.

Admin actions defaults

OptionDefault behaviorWhen to override
getS3ConfigReads from S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars. Returns null if any required var is missing. Also reads optional S3_ENDPOINT, S3_PUBLIC_URL_BASE, S3_ACL.Your S3 credentials use different env var names, or you configure multiple buckets.
getUploadConfigReturns null (no image processing).You want automatic image resizing. Pass (slug) => vextro.getUploadConfig(slug) using your schema instance.
adminApiNot provided. Image processing and orphan cleanup log a warning and skip.You want file operations to work. Pass { getFileRecord: api.admin.getFileRecord, listOrphanedFiles: api.admin.listOrphanedFiles, deleteOrphanedFiles: api.admin.deleteOrphanedFiles }.
scheduleRetryNot provided. Failed webhook deliveries are not retried.You want exponential backoff retries. Pass async (ctx, delay, args) => { await ctx.scheduler.runAfter(delay, internal.adminActions.deliverWebhook, args); }.

Full setup with all overrides

// convex/adminActions.ts
"use node";
import { internalAction } from "./_generated/server";
import { api, internal, components } from "./_generated/api";
import { createVextroAdminActions } from "vextro/convex/adminActions";
import { vextro } from "./schema";

const actions = createVextroAdminActions({
  internalAction,
  components,
  getUploadConfig: (slug) => vextro.getUploadConfig(slug),
  adminApi: {
    getFileRecord: api.admin.getFileRecord,
    listOrphanedFiles: api.admin.listOrphanedFiles,
    deleteOrphanedFiles: api.admin.deleteOrphanedFiles,
  },
  scheduleRetry: async (ctx, delay, args) => {
    await ctx.scheduler.runAfter(delay, internal.adminActions.deliverWebhook, args);
  },
});

export const { processUploadedImage, cleanupOrphanedFiles, deliverWebhook } = actions;
ActionDescription
processUploadedImageResizes and converts uploaded images via Sharp, stores variants in S3
cleanupOrphanedFilesQueries orphaned file records, batch-deletes from S3, removes DB records
deliverWebhookHTTP POST with HMAC signing, exponential backoff retry, attempt logging

Image processing mutation

Vextro also provides a factory for the public mutation that schedules image processing. This replaces the boilerplate scheduleImageProcessing mutation most projects define manually.

// convex/admin.ts
import { mutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { createVextroScheduleImageProcessing } from "vextro/convex/scheduleImageProcessing";

export const scheduleImageProcessing = createVextroScheduleImageProcessing({
  mutation,
  processUploadedImageRef: internal.adminActions.processUploadedImage,
});

Presence wrapper

The presence cleanup cron needs a wrapper mutation in your app's Convex module (crons require references within the same component). Vextro provides a factory:

// convex/presence.ts
import { internalMutation } from "./_generated/server";
import { components } from "./_generated/api";
import { createVextroPresenceWrapper } from "vextro/convex/presenceWrapper";

export const { cleanupStalePresence } =
  createVextroPresenceWrapper({ internalMutation, components });

Field-level presence

When multiple users edit the same document, Vextro shows real-time field-level indicators:

  • Focused field badge — a small colored pill above any field another user has focused, showing their initials (e.g., JD for John Doe).
  • Dirty field border — a colored left border on any field another user has modified but not yet saved.

Field-level presence is enabled automatically on collections that have hasPresence: true and a configured presence endpoint.

Custom presence labels

By default, badges show the first two initials extracted from the user's name. Override this with presenceLabelFn in your VextroConfig:

const config: VextroConfig = {
  brandName: "My App",
  presenceLabelFn: (user) => user.userName.split(" ")[0],
};

The function receives { userId, userName, userEmail? } and should return a short display string (1–3 characters works best).

Crons helper

Register standard Vextro cron jobs (orphaned file cleanup + stale presence cleanup) with a single call:

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
import { addVextroCrons } from "vextro/convex/cronsHelper";

const crons = cronJobs();

addVextroCrons(crons, {
  cleanupOrphanedFiles: internal.adminActions.cleanupOrphanedFiles,
  cleanupStalePresence: internal.presence.cleanupStalePresence,
  intervals: {
    orphanedFilesHours: 1,     // default: 1 hour
    stalePresenceMinutes: 5,   // default: 5 minutes
  },
});

export default crons;

TipTap rich text sync

Vextro uses @convex-dev/prosemirror-sync for real-time collaborative rich text editing. Mount the sync component and expose its API using the helper factory. Only components is required — auth guards default to checking for an authenticated identity.

// convex/prosemirrorSync.ts
import { components } from "./_generated/api";
import { createVextroProsemirrorSync } from "vextro/convex/prosemirrorHelper";

export const {
  getSnapshot,
  submitSnapshot,
  latestVersion,
  getSteps,
  submitSteps,
} = createVextroProsemirrorSync({ components });

To add role-based guards, pass requireRead and/or requireWrite:

export const { getSnapshot, submitSnapshot, latestVersion, getSteps, submitSteps } =
  createVextroProsemirrorSync({
    components,
    requireRead: async (ctx) => {
      await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);
    },
    requireWrite: async (ctx) => {
      await requirePermission(ctx, "cms:admin");
    },
  });

Then pass the sync API to your admin app config so VextroWysiwygTipTap can connect:

// Admin app config
const vextroConfig = {
  inputOverrides: createVextroInputOverrides({
    syncApi: api.prosemirrorSync,
  }),
};

Rich text fields opt into TipTap by setting admin.componentKey:

content: f.richText()
// Internally maps to componentKey: "wysiwyg.tiptap"

Blocks model

Blocks live in their own Convex tables. Documents store an ordered array of block references:

// Stored on the parent document
blocks: [
  { blockType: "hero", blockId: "k57abc...", order: 0 },
  { blockType: "cta", blockId: "k57def...", order: 1 },
]

Each block type has its own table, fields, and admin config. Define blocks with defineVextroBlock and reference them in collection fields with f.blocks().

import { f, defineVextroBlock } from "vextro";

export const hero = defineVextroBlock({
  slug: "hero",
  label: "Hero",
  tableName: "hero_blocks",
  group: "Layout",
  fields: {
    heading: f.text({ required: true }),
    subheading: f.text(),
    backgroundImage: f.image({ relationTo: "media" }),
    ctaLabel: f.text(),
    ctaUrl: f.url(),
  },
});

// In a collection definition
fields: {
  pageBlocks: f.blocks([hero, cta, testimonial]),
}

Separate tables, not inline

Vextro stores blocks in dedicated tables rather than inline JSON. This enables block reuse across documents, independent versioning, and efficient queries. The tradeoff is an extra lookup per block when rendering.

Cache invalidation

Vextro caches shell data (sidebar navigation, collection list) and collection page data in memory. You can invalidate caches via a Convex HTTP endpoint or the admin app.

Convex HTTP endpoint

# Invalidate all caches
curl -X POST https://your-convex-url/vextro/cache/invalidate \
  -H "Content-Type: application/json" \
  -d '{"scope": "all"}'

# Invalidate a specific collection
curl -X POST https://your-convex-url/vextro/cache/invalidate \
  -H "Content-Type: application/json" \
  -d '{"scope": "collection", "prefix": "posts:"}'

Admin app endpoint

POST /api/vextro/cache/invalidate

Both endpoints accept the same payload and require VEXTRO_CACHE_SECRET in production.

Payload shape

{
  scope: "shell" | "collection" | "all",
  key?: string,           // Exact cache key to invalidate
  prefix?: string,        // Invalidate keys starting with this prefix
  keys?: string[],        // Batch: multiple exact keys
  prefixes?: string[],    // Batch: multiple prefixes
}

Cache stats

Monitor cache performance with the stats endpoints:

GET /vextro/cache/stats
GET /api/vextro/cache/stats

Returns hit/miss/set/clear counts for both shell and collection caches.

Automatic cache invalidation

Cache invalidation action

Vextro provides a factory that creates the standard cache invalidation action. It reads PUBLIC_ADMIN_HOST and VEXTRO_CACHE_SECRET from environment variables and automatically skips invalidation for local dev URLs.

// convex/admin.ts
import { action } from "./_generated/server";
import { createVextroCacheInvalidationAction } from "vextro/convex/cacheInvalidation";

export const invalidateVextroCache = createVextroCacheInvalidationAction({ action });

Wiring to the admin module

Pass a scheduleCacheInvalidation callback to trigger invalidation when documents change:

const admin = createVextroAdminModule({
  // ...
  scheduleCacheInvalidation: async (ctx, args) => {
    await ctx.scheduler.runAfter(0, api.admin.invalidateVextroCache, args);
  },
});

Input overrides

Replace Vextro's default field inputs with custom components by providing an inputOverrides map in your admin config.

const vextroConfig = {
  inputOverrides: {
    components: {
      "wysiwyg.tiptap": CustomTipTapEditor,
      "mapPicker": CustomMapPicker,
    },
    client: {
      "wysiwyg.tiptap": "solid",
      "mapPicker": "solid",
    },
  },
};

Fields opt into custom inputs via admin.componentKey in their field definition or by using f.custom() with a matching type string.

Previous
Virtual Collections
Next
Routing