Configuration
Collections
Overview
Collections are the core building block of Vextro. Each collection represents a repeating content type -- blog posts, pages, products, team members -- backed by a Convex table. Vextro reads the collection definition at startup and generates list views, document editors, sidebar navigation, and search automatically.
You define collections once using defineVextroCollection and the f field builder. The definition produces both the Convex schema validator and the admin UI metadata from a single source of truth.
Defining a collection
The slug field is optional and defaults to tableName when omitted. You can still provide an explicit slug if you need the URL identifier to differ from the table name.
import { f, defineVextroCollection } from "vextro";
export const posts = defineVextroCollection({
// slug defaults to "posts" (the tableName)
label: "Blog Posts",
description: "Articles published on the company blog",
group: "Content",
collectionType: "content",
tableName: "posts",
useAsTitle: "title",
fields: {
title: f.text({ required: true, searchable: true, listColumn: true }),
slug: f.slug({ sourceField: "title", required: true }),
author: f.id("users", { required: true, listColumn: true }),
excerpt: f.textarea({ rows: 3 }),
content: f.richText(),
featuredImage: f.image({ relationTo: "media" }),
publishedAt: f.datetime({ sidebar: true, sidebarSection: "scheduling" }),
category: f.select({
options: ["news", "engineering", "tutorial", "announcement"],
required: true,
listColumn: true,
}),
featured: f.checkbox({ sidebar: true, sidebarSection: "document" }),
},
listConfig: {
columns: ["title", "category", "status", "publishedAt"],
searchableFields: ["title", "slug"],
defaultSort: "publishedAt",
defaultSortDirection: "desc",
},
access: {
read: "cms:read",
create: "cms:write",
update: "cms:write",
delete: "cms:admin",
},
versions: { enabled: true, maxVersions: 25 },
}); When the slug needs to differ from the table name, pass it explicitly:
export const blogPosts = defineVextroCollection({
slug: "blog", // URL path uses "blog"
tableName: "blog_posts", // Convex table is "blog_posts"
label: "Blog Posts",
collectionType: "content",
fields: { /* ... */ },
}); Configuration reference
The full set of options accepted by defineVextroCollection:
| Option | Type | Default | Description |
|---|---|---|---|
tableName* | string | -- | Convex table name |
label* | string | -- | Display label in admin UI |
slug | string | tableName | URL-safe identifier for routes |
description | string | -- | Description shown in admin UI |
group | string | -- | Sidebar group heading |
collectionType | string | -- | Semantic label; built-in values map to trait presets |
traits | Partial<VextroCollectionTraits> | see presets | Behavioral feature flags |
fields* | VextroFieldsInput | -- | Field definitions using f builders |
useAsTitle | string | -- | Field name used as document title in admin |
access | VextroAccessConfig | -- | Permission strings for CRUD operations |
listConfig | VextroListConfig | -- | List view columns, search, and sort |
versions | VextroVersioningConfig | -- | Version history configuration |
upload | boolean | UploadConfig | -- | File upload support |
preview | VextroPreviewConfig | -- | Live preview URL for the document editor |
workflow | WorkflowConfig | -- | Multi-editor approval chains |
localization | VextroLocalizationConfig | -- | Multi-language content |
hierarchy | VextroHierarchyConfig | -- | Nested document trees |
hooks | CollectionHooks | -- | Lifecycle hooks (beforeChange, afterChange, etc.) |
autoSave | boolean | { enabled?: boolean; debounceMs?: number } | -- | Auto-save configuration. true enables with defaults (3000ms debounce). Object form allows custom debounce. |
statusConfig | VextroStatusConfig | -- | Customize status field name, values, and index |
fieldNames | VextroCollectionFieldNames | -- | Override auto-injected field names |
sidebarConfig | VextroSidebarConfig | -- | Sidebar section ordering |
sidebar | boolean | true | Whether collection appears in the admin sidebar |
scope | VextroCollectionScope | -- | Multi-tenant / regional content partitioning |
picker | VextroCollectionPicker | -- | Picker dialog grouping, thumbnail, and icon |
indexes | VextroIndexDefinition[] | [] | Additional Convex indexes beyond auto-generated ones |
blockBehavior | boolean | VextroBlockBehaviorConfig | -- | Opt in as a block type |
Options marked with * are required.
Field-level hooks are defined inline on individual field builders using the hooks option (e.g., f.text({ hooks: { beforeChange: [...] } })), not as a top-level collection option. They are extracted at build time and stored on the resolved definition's .fieldHooks property. See the Fields overview for details.
Collection traits
Traits are behavioral flags that control which automatic features a collection gets. Each trait can be toggled independently using the traits option:
export const employees = defineVextroCollection({
label: "Employees",
tableName: "employees",
collectionType: "operational", // semantic label for sidebar grouping (any string)
traits: {
statusWorkflow: false, // no draft/publish lifecycle
autoTimestamps: true, // auto-update updatedAt on mutations
auditFields: true, // track updatedBy and createdBy
versioning: true, // enable version history on save
softDelete: false, // hard delete (no trash)
},
fields: {
name: f.text({ required: true }),
department: f.select({ options: ["engineering", "marketing", "sales"] }),
},
}); Available traits
| Trait | Default | Effect |
|---|---|---|
statusWorkflow | false | Adds a status field (draft/published/scheduled/trashed), creates a by_status index, shows status selector in admin sidebar |
autoTimestamps | true | Auto-injects updatedAt field and updates it on every mutation |
auditFields | true | Auto-injects updatedBy and createdBy fields |
versioning | false | Enables version history on save (shorthand for versions: { enabled: true }) |
softDelete | false | Move to trash instead of hard delete (reserved for future implementation) |
Collection type presets
The collectionType option serves two purposes: it provides a semantic label for sidebar grouping and icons, and when no explicit traits are provided, it maps to a trait preset. Any string is accepted for custom grouping.
| Type | Trait preset |
|---|---|
"content" | statusWorkflow: true, autoTimestamps: true, auditFields: true |
"config" | autoTimestamps: true, auditFields: true |
"system" | autoTimestamps: true, auditFields: true |
When both collectionType and traits are provided, explicit traits take precedence over the preset. When neither is provided, defaults apply (autoTimestamps: true, auditFields: true, everything else false).
// These two are equivalent:
defineVextroCollection({
collectionType: "content",
// ...
});
defineVextroCollection({
collectionType: "content",
traits: { statusWorkflow: true, autoTimestamps: true, auditFields: true },
// ...
});
// Override a preset: content type but without status workflow
defineVextroCollection({
collectionType: "content",
traits: { statusWorkflow: false },
// ...
}); Migration
If upgrading from a version that used collectionType as a required behavior toggle, re-seed your admin metadata after upgrading. The presets ensure existing collectionType values map to the same behavior as before.
Field configuration
Fields are defined using the f builder. Every field accepts common options for labels, descriptions, validation, conditional visibility, and admin placement.
fields: {
// Required text with search indexing
title: f.text({ required: true, searchable: true }),
// Auto-generated slug from another field
slug: f.slug({ sourceField: "title", required: true }),
// Relationship to another table
author: f.id("users", { required: true }),
// Select with labeled options
priority: f.select({
options: [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
],
}),
// Conditional field: only visible when priority is "high"
escalationNotes: f.textarea({
condition: { field: "priority", equals: "high" },
}),
} Field names become schema fields
Every key in the fields object maps directly to a Convex document field. Vextro extracts both the Convex validator and the admin UI config from the same definition, so your schema and admin always stay in sync.
Sidebar placement
Fields can be placed in the document sidebar instead of the main content area. Use sidebar: true and optionally group them with sidebarSection.
fields: {
status: f.select({
sidebar: true,
sidebarSection: "document",
options: ["draft", "published", "scheduled", "trashed"],
}),
slug: f.slug({
sourceField: "title",
sidebar: true,
sidebarSection: "document",
copyable: true,
}),
publishAt: f.datetime({
sidebar: true,
sidebarSection: "scheduling",
}),
} Fields without a sidebarSection render directly in the sidebar without a collapsible wrapper or heading. Built-in sections are "_default", "document", "actions", and "activity". The default order is: ungrouped fields (_default), custom sections (in definition order), actions, document info, and activity.
Sidebar section order
You can override the default sidebar section order with sidebarConfig.sectionOrder. This controls how the configurable sections are arranged below the pinned sections (status, scheduling, workflow, references).
defineVextroCollection({
slug: "pages",
tableName: "pages",
fields: { /* ... */ },
sidebarConfig: {
sectionOrder: ["actions", "_default", "document", "activity"],
},
}); The sectionOrder array accepts any combination of built-in and custom section IDs. Sections not listed in the array are appended at the end. The default order when no sectionOrder is specified is: "_default" (ungrouped fields), custom sections (in definition order), "actions", "document", "activity".
The "_default" section contains fields that have sidebar: true but no explicit sidebarSection. These fields render directly without a collapsible wrapper or heading. You can place "_default" anywhere in sectionOrder to reorder it.
List view configuration
The listConfig object controls how documents appear in the collection list page.
listConfig: {
columns: ["title", "status", "author", "publishedAt"],
searchableFields: ["title", "slug", "excerpt"],
defaultSort: "publishedAt",
defaultSortDirection: "desc",
}, Individual fields can also declare listColumn: true and listColumnWidth to participate in the default column set.
Access control
Permission strings in the access object are checked against the current user's permissions via vextro/auth guards. All four operations can be independently controlled.
access: {
read: "cms:read",
create: "cms:write",
update: "cms:write",
delete: "cms:admin",
}, Server-side enforcement
Access checks are enforced in Convex mutations and queries through the requireAdminRead and requireAdminWrite callbacks. Client-side permission gating is cosmetic only.
Upload configuration
Enable file uploads for a collection by setting upload: true for defaults, or pass an UploadConfig object for fine-grained control:
export const media = defineVextroCollection({
label: "Media",
tableName: "media",
upload: {
mimeTypes: ["image/*", "application/pdf"],
maxFileSize: 10 * 1024 * 1024, // 10 MB
imageSizes: [
{ name: "thumbnail", width: 200, height: 200 },
{ name: "card", width: 600 },
],
focalPoint: true,
crop: true,
bulkUpload: true,
displayPreview: true,
},
fields: {
alt: f.text(),
caption: f.textarea(),
},
}); Upload options
| Option | Type | Default | Description |
|---|---|---|---|
mimeTypes | string[] | -- | Restrict accepted MIME types (e.g. ["image/*"]) |
maxFileSize | number | -- | Maximum file size in bytes |
imageSizes | ImageSizeConfig[] | -- | Auto-generated image variants on upload |
focalPoint | boolean | true when imageSizes defined | Show focal point selector |
crop | boolean | true | Enable crop tool |
formatOptions | FormatOptions | -- | Output format conversion |
resizeOptions | object | -- | Resize original (width, height, fit) |
adminThumbnail | string | function | -- | Image size name or custom function for admin thumbnails |
bulkUpload | boolean | true | Enable bulk upload from list view |
displayPreview | boolean | true | Show preview in upload fields |
filesRequiredOnCreate | boolean | true | Require file on document creation |
pasteURL | boolean | object | -- | Allow pasting URLs to fetch files |
storageAdapter | string | global adapter | Override the storage adapter |
prefix | string | -- | Prefix for storage paths |
See the Uploads page for the full storage adapter and image pipeline guide.
Live preview
Configure a live preview panel in the document editor that updates in real-time as the user edits:
export const pages = defineVextroCollection({
label: "Pages",
tableName: "pages",
collectionType: "content",
preview: {
url: "/preview/{slug}",
enabled: true,
},
fields: {
title: f.text({ required: true }),
slug: f.slug({ sourceField: "title", required: true }),
body: f.richText(),
},
}); The url string supports {fieldName} placeholders that are replaced with current field values. Use {_id} to reference the document ID.
| Option | Type | Default | Description |
|---|---|---|---|
url* | string | -- | URL template with {fieldName} placeholders |
enabled | boolean | true | Whether the preview panel is active |
Workflow
Enable multi-editor approval chains so documents pass through review stages before publishing:
export const articles = defineVextroCollection({
label: "Articles",
tableName: "articles",
collectionType: "content",
workflow: {
enabled: true,
stages: [
{ name: "review", label: "Editorial Review" },
{ name: "legal", label: "Legal Approval" },
],
},
fields: {
title: f.text({ required: true }),
body: f.richText(),
},
}); When workflow is enabled, documents progress through the defined stages between draft and published. Each stage can require approval from designated editors.
| Option | Type | Default | Description |
|---|---|---|---|
enabled* | boolean | -- | Whether workflow is active |
stages* | WorkflowStageDefinition[] | -- | Ordered stages between draft and published |
See the Workflow page for the full approval chain guide.
Localization
Configure multi-language content for a collection:
export const pages = defineVextroCollection({
label: "Pages",
tableName: "pages",
localization: {
enabled: true,
locales: ["en", "es", "fr", "de"],
defaultLocale: "en",
localizedFields: ["title", "body", "excerpt"],
},
fields: {
title: f.text({ required: true }),
slug: f.slug({ sourceField: "title", required: true }),
body: f.richText(),
excerpt: f.textarea(),
featuredImage: f.image({ relationTo: "media" }),
},
}); Default locale fields are stored at the top level of the document. Other locales are stored in a nested object. If localizedFields is omitted or empty, all fields are localized.
| Option | Type | Default | Description |
|---|---|---|---|
enabled* | boolean | -- | Whether localization is active |
locales* | string[] | -- | Available locale codes (e.g. ["en", "es"]) |
defaultLocale* | string | -- | Default locale stored at the top level |
localizedFields | string[] | all fields | Specific fields to localize |
Hierarchy
Enable nested document trees for categories, pages, or any content that needs parent–child relationships:
export const categories = defineVextroCollection({
label: "Categories",
tableName: "categories",
hierarchy: {
parentField: "parent",
maxDepth: 5,
},
fields: {
name: f.text({ required: true }),
parent: f.id("categories"),
},
}); When hierarchy is configured, Vextro automatically:
- Generates a
by_{parentField}index for tree queries - Enables tree navigation and breadcrumbs in the admin UI
- Enforces circular reference protection on save
| Option | Type | Default | Description |
|---|---|---|---|
parentField | string | "parent" | Field name holding the parent document reference |
maxDepth | number | 10 | Maximum nesting depth |
See the Hierarchy page for the full tree navigation guide.
Hooks
Attach lifecycle hooks to run custom logic before or after CRUD operations:
export const orders = defineVextroCollection({
label: "Orders",
tableName: "orders",
hooks: {
beforeChange: [
async ({ data, operation, ctx }) => {
if (operation === "create") {
data.orderNumber = await generateOrderNumber(ctx);
}
return data;
},
],
afterChange: [
async ({ doc, operation, ctx }) => {
if (operation === "create") {
await sendOrderConfirmation(ctx, doc);
}
},
],
beforeDelete: [
async ({ id, ctx }) => {
await archiveOrderData(ctx, id);
},
],
},
fields: {
orderNumber: f.text(),
total: f.number({ required: true }),
items: f.json(),
},
}); | Hook | Arguments | Description |
|---|---|---|
beforeChange | { data, operation, ctx, existingDoc? } | Runs before create/update. Return modified data. |
afterChange | { doc, operation, ctx, previousDoc? } | Runs after create/update. |
beforeDelete | { id, ctx, doc } | Runs before deletion. |
afterDelete | { id, ctx, doc } | Runs after deletion. |
beforeRead | { ctx, query } | Runs before read queries. |
afterRead | { ctx, docs } | Runs after read queries. |
Each hook slot accepts an array of functions that execute in order. See the Hooks page for the full lifecycle reference.
Auto-save
Enable auto-save so the document editor saves in the background as the user types, without requiring an explicit save action.
export const posts = defineVextroCollection({
label: "Posts",
tableName: "posts",
autoSave: {
enabled: true,
debounceMs: 2000,
},
fields: {
title: f.text({ required: true }),
body: f.richText(),
},
}); Pass autoSave: true as shorthand to enable auto-save with the default 3000 ms debounce:
defineVextroCollection({
slug: "drafts",
label: "Drafts",
tableName: "drafts",
autoSave: true, // enabled, debounceMs: 3000
fields: {
title: f.text({ required: true }),
content: f.richText(),
},
}); | Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether auto-save is active for this collection |
debounceMs | number | 3000 | Milliseconds to wait after the last change before saving |
When autoSave is omitted, the collection inherits the project-level autoSave setting from VextroConfig. An explicit false disables auto-save for the collection even when the global setting is enabled.
When to enable auto-save
- Collaborative collections where multiple editors work simultaneously — auto-save ensures edits are visible to other participants faster.
- Long-form content (rich text, complex forms) where a browser crash or accidental close would lose significant unsaved work.
- Frequently edited collections where requiring a manual save on every change adds unnecessary friction for editors.
When to disable auto-save
- Collections with expensive side effects — if
afterChangehooks send webhooks, process images, or call external APIs, every debounced save fires those effects. Disable auto-save and let editors save explicitly. - Publishing workflows where draft quality matters — accidental partial saves (mid-sentence, mid-form) can leave documents in inconsistent states when saves are triggered by a status-change hook or audit system.
- Low-edit-frequency collections (settings, configuration, globals) — explicit saves give editors intentional control and prevent noise in version history.
Debounce tuning
| Range | Use case |
|---|---|
| 1000–2000 ms | Fast-paced collaborative editing. More frequent saves, higher server mutation rate. |
| 3000 ms | Default — balanced for most editorial workflows. |
| 5000 ms+ | Single-editor long-form content. Reduces mutation frequency at the cost of slightly stale presence data. |
Auto-save and versioning
When both autoSave and versions are enabled, each auto-save creates a new version snapshot. Use maxVersions to cap history growth, or consider disabling auto-save in favor of manual saves for collections where a clean version history matters.
Status configuration
Customize the status field, available values, and index name for collections with the statusWorkflow trait:
export const articles = defineVextroCollection({
label: "Articles",
tableName: "articles",
collectionType: "content",
statusConfig: {
field: "publishStatus",
values: ["draft", "in_review", "published", "archived"],
index: "by_publish_status",
},
fields: {
title: f.text({ required: true }),
},
}); | Option | Type | Default | Description |
|---|---|---|---|
field | string | "status" | Field name for the status value |
values | string[] | ["draft", "published", "scheduled", "trashed"] | Available status options |
index | string | "by_status" | Convex index name for status queries |
Custom field names
Override the names of auto-injected fields when they conflict with your schema or conventions:
export const products = defineVextroCollection({
label: "Products",
tableName: "products",
fieldNames: {
status: "productStatus",
updatedAt: "lastModified",
updatedBy: "modifiedBy",
createdBy: "addedBy",
slug: "urlKey",
},
fields: {
name: f.text({ required: true }),
urlKey: f.slug({ sourceField: "name", required: true }),
},
}); | Option | Type | Default | Description |
|---|---|---|---|
status | string | "status" | Status field name |
updatedAt | string | "updatedAt" | Timestamp field name |
updatedBy | string | "updatedBy" | Updated-by audit field name |
createdBy | string | "createdBy" | Created-by audit field name |
slug | string | -- | Slug field name (enables by_slug index when set) |
Sidebar visibility
Hide a collection from the admin sidebar while keeping it accessible via direct URL:
export const internalSettings = defineVextroCollection({
label: "Internal Settings",
tableName: "internalSettings",
sidebar: false, // hidden from sidebar navigation
fields: {
key: f.text({ required: true }),
value: f.json(),
},
}); When sidebar is false, the collection does not appear in the admin navigation but remains fully functional and accessible at its admin URL.
Custom indexes
Add Convex indexes beyond the ones Vextro auto-generates (status, slug, scope, hierarchy):
export const products = defineVextroCollection({
label: "Products",
tableName: "products",
indexes: [
{ name: "by_sku", fields: ["sku"] },
{ name: "by_category_price", fields: ["category", "price"] },
],
fields: {
sku: f.text({ required: true }),
category: f.select({ options: ["electronics", "clothing", "home"] }),
price: f.number({ required: true }),
},
}); Each index definition requires a name and a fields array (at least one field). These indexes are added alongside the auto-generated ones. If a custom index name matches an auto-generated index name, the custom definition takes priority.
Picker configuration
Customize how a collection appears in picker dialogs (e.g., when selecting a relationship target):
export const teamMembers = defineVextroCollection({
label: "Team Members",
tableName: "teamMembers",
picker: {
group: "People",
thumbnail: "/images/team-icon.svg",
icon: "👤",
},
fields: {
name: f.text({ required: true }),
role: f.text(),
},
}); | Option | Type | Default | Description |
|---|---|---|---|
group | string | -- | Group label in picker dialogs |
thumbnail | string | -- | Static thumbnail image URL |
icon | string | -- | Emoji icon for inline rendering |
Versioning
Enable version history so document snapshots are saved before each update:
export const policies = defineVextroCollection({
label: "Policies",
tableName: "policies",
versions: {
enabled: true,
maxVersions: 50,
},
fields: {
title: f.text({ required: true }),
body: f.richText(),
},
}); | Option | Type | Default | Description |
|---|---|---|---|
enabled* | boolean | -- | Whether version history is active |
maxVersions | number | 25 | Maximum versions to keep per document |
You can also enable versioning via the versioning trait, which is shorthand for versions: { enabled: true }. When both traits.versioning and versions are set, the versions object takes precedence for maxVersions.
See the Version History page for the full versioning guide.
Using collections in your schema
The resolved collection definition includes a .table property that you can spread directly into defineSchema.
// convex/schema.ts
import { defineSchema } from "convex/server";
import { posts } from "./collections/posts";
import { pages } from "./collections/pages";
export default defineSchema({
posts: posts.table,
pages: pages.table,
}); For multiple collections at once, use createVextroCollectionsSchema:
import { createVextroCollectionsSchema } from "vextro";
const { tables } = createVextroCollectionsSchema({
collections: [posts, pages, categories],
});
export default defineSchema({ ...tables }); Registering with the admin module
Pass your collection definitions to buildAdminDefinitions and then to the admin module so Vextro can generate the UI.
// convex/admin.ts
import { buildAdminDefinitions } from "vextro/convex/admin";
import { posts } from "./collections/posts";
import { pages } from "./collections/pages";
const collectionDefinitions = buildAdminDefinitions({
collections: [posts, pages],
}); See the Convex Component page for the full admin module setup.
Block behavior
Collections can opt in as block types, making them available in the block picker alongside component-hosted blocks. Set blockBehavior: true for defaults, or pass a config object to customize the block slug, label, and picker appearance:
export const heroSections = defineVextroCollection({
label: "Hero Sections",
tableName: "heroSections",
blockBehavior: true, // opt in with defaults (slug, label from collection)
fields: {
heading: f.text({ required: true }),
subheading: f.textarea(),
backgroundImage: f.image({ relationTo: "media" }),
},
});
export const testimonials = defineVextroCollection({
label: "Testimonials",
tableName: "testimonials",
blockBehavior: {
slug: "testimonial", // override the block slug
label: "Testimonial Block", // override the block label
picker: { group: "Social Proof", icon: "💬" },
},
fields: {
quote: f.textarea({ required: true }),
author: f.text({ required: true }),
},
}); Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
blockBehavior | boolean | VextroBlockBehaviorConfig | -- | Opt this collection in as a block type |
blockBehavior.slug | string | collection slug | Override the block slug |
blockBehavior.label | string | collection label | Override the block label in the picker |
blockBehavior.picker | VextroBlockPicker | collection picker | Picker group, thumbnail, and icon |
Using in block fields
Collections with blockBehavior can be passed directly to f.blocks() alongside component block definitions:
import { hero, cta } from "./blocks";
import { heroSections } from "./collections";
export const pages = defineVextroCollection({
label: "Pages",
collectionType: "content",
tableName: "pages",
fields: {
title: f.text({ required: true }),
content: f.blocks([hero, cta, heroSections]),
},
}); Vextro automatically routes each block to the correct source using the componentSource discriminant: component blocks use "vextroBlocks" and collection blocks use "main".
Collections with blockBehavior passed to createVextroSchema() are also auto-collected as main-app blocks in the schema, so you don't need to repeat them in blocks.mainAppBlocks.
See the Collection Opt-In page for the full mixed-source blocks guide.
Scopes
Collections can be partitioned by scope for multi-tenant or regional content filtering. Add a scope config to any collection:
export const stores = defineVextroCollection({
// ...
fields: {
name: f.text({ required: true }),
regionScope: f.id("regions", { required: true }),
},
scope: {
field: "regionScope",
type: "region",
},
}); When scope is configured, Vextro automatically generates a by_{field} index and filters queries, mutations, and the admin UI by the active scope. See Scopes & Multi-Tenancy for the full setup guide.