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
| Option | Default behavior | When to override |
|---|---|---|
getCurrentUser | Queries 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). |
requireAdminRead | Calls ctx.auth.getUserIdentity() and throws "Not authenticated" if null. | You need role-based or permission-based read guards. |
requireAdminWrite | Same as requireAdminRead — checks for any authenticated identity. | You need stricter write guards (e.g., require an admin or editor role). |
isTableName | Treats all strings as valid table names. | You want runtime validation that a table name exists in your schema. |
getSampleDoc | Reads 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
| Property | Type | Required | Description |
|---|---|---|---|
query | QueryBuilder | Yes | Convex query builder from _generated/server |
mutation | MutationBuilder | Yes | Convex mutation builder from _generated/server |
components | VextroAdminComponentRefs | Yes | Component references from _generated/api |
collectionDefinitions | Array<CollectionDefinition> | No | Collection definitions from buildAdminDefinitions() |
globalDefinitions | Array<GlobalDefinition> | No | Global definitions for singleton documents |
isTableName | (name: string) => name is TableName | No | Type guard for runtime table name validation |
getCurrentUser | (ctx) => Promise<User | null> | No | Resolve the authenticated user for audit/presence |
requireAdminRead | (ctx) => Promise<void> | No | Auth guard for read operations; throw to deny |
requireAdminWrite | (ctx) => Promise<void> | No | Auth guard for write operations; throw to deny |
getUserRoles | (ctx) => Promise<string[]> | No | Return role slugs for the current user (RBAC) |
rbacFailOpen | boolean | No | If true, role resolution errors silently bypass RBAC (default false) |
accessControl | AccessControlDefinitions | No | Code-defined roles, permissions, and table overrides |
requireAccessControlRead | (ctx) => Promise<void> | No | Auth guard for role/user management queries |
requireAccessControlWrite | (ctx) => Promise<void> | No | Auth guard for role/user management mutations |
getUserScopes | (ctx, scopeType) => Promise<string[] | null> | No | Return scope IDs the user can access; null = unrestricted |
canAccessScope | (ctx, scopeType, scopeValue) => Promise<boolean> | No | Validate user access to a specific scope value |
listScopeOptions | (ctx, scopeType) => Promise<Array<{ id, label }>> | No | Provide options for the scope selector UI |
scheduleCacheInvalidation | (ctx, args) => Promise<void> | No | Schedule cache invalidation after document changes |
scheduleWebhookDelivery | (ctx, args) => Promise<void> | No | Schedule webhook HTTP delivery as an action |
userProfileFields | string[] | No | Fields users can edit on their own profile (default: ["displayName", "avatarUrl"]) |
onUserStatusChange | (ctx, { userId, previousStatus, newStatus }) => Promise<void> | No | Hook called when a user's status changes |
blockDeletionStrategy | "preserve" | "delete-exclusive" | No | How to handle blocks when a parent document is deleted (default: "preserve") |
richText | { toolbar?: RichTextToolbar } | No | Project-level rich text editor defaults |
getSampleDoc | (ctx, definition) => Promise<Record | undefined> | No | Return a sample document for a collection |
getCollectionDefinitions | (ctx) => Promise<Array<CollectionDefinition>> | No | Dynamic 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
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Set to false to skip RBAC table generation |
tables | object | See below | Override default table names |
userFields | Record<string, Validator> | {} | Additional fields merged into the users table |
userIndexes | Array<{ 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.
| Option | Type | Description |
|---|---|---|
permissions | Array<{ slug, description }> | Permission definitions to sync to the database |
roles | Array<{ slug, name, description, permissions }> | Role definitions with default permission assignments |
tables | object | Table 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. |
userProfileFields | string[] | Fields users can edit on their own profile. Default: ["displayName", "avatarUrl"]. |
Table name overrides
| Key | Default | Description |
|---|---|---|
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
| Option | Default behavior | When to override |
|---|---|---|
getS3Config | Reads 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. |
getUploadConfig | Returns null (no image processing). | You want automatic image resizing. Pass (slug) => vextro.getUploadConfig(slug) using your schema instance. |
adminApi | Not 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 }. |
scheduleRetry | Not 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; | Action | Description |
|---|---|
processUploadedImage | Resizes and converts uploaded images via Sharp, stores variants in S3 |
cleanupOrphanedFiles | Queries orphaned file records, batch-deletes from S3, removes DB records |
deliverWebhook | HTTP 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.,
JDfor 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.