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:

OptionTypeDefaultDescription
tableName*string--Convex table name
label*string--Display label in admin UI
slugstringtableNameURL-safe identifier for routes
descriptionstring--Description shown in admin UI
groupstring--Sidebar group heading
collectionTypestring--Semantic label; built-in values map to trait presets
traitsPartial<VextroCollectionTraits>see presetsBehavioral feature flags
fields*VextroFieldsInput--Field definitions using f builders
useAsTitlestring--Field name used as document title in admin
accessVextroAccessConfig--Permission strings for CRUD operations
listConfigVextroListConfig--List view columns, search, and sort
versionsVextroVersioningConfig--Version history configuration
uploadboolean | UploadConfig--File upload support
previewVextroPreviewConfig--Live preview URL for the document editor
workflowWorkflowConfig--Multi-editor approval chains
localizationVextroLocalizationConfig--Multi-language content
hierarchyVextroHierarchyConfig--Nested document trees
hooksCollectionHooks--Lifecycle hooks (beforeChange, afterChange, etc.)
autoSaveboolean | { enabled?: boolean; debounceMs?: number }--Auto-save configuration. true enables with defaults (3000ms debounce). Object form allows custom debounce.
statusConfigVextroStatusConfig--Customize status field name, values, and index
fieldNamesVextroCollectionFieldNames--Override auto-injected field names
sidebarConfigVextroSidebarConfig--Sidebar section ordering
sidebarbooleantrueWhether collection appears in the admin sidebar
scopeVextroCollectionScope--Multi-tenant / regional content partitioning
pickerVextroCollectionPicker--Picker dialog grouping, thumbnail, and icon
indexesVextroIndexDefinition[][]Additional Convex indexes beyond auto-generated ones
blockBehaviorboolean | 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

TraitDefaultEffect
statusWorkflowfalseAdds a status field (draft/published/scheduled/trashed), creates a by_status index, shows status selector in admin sidebar
autoTimestampstrueAuto-injects updatedAt field and updates it on every mutation
auditFieldstrueAuto-injects updatedBy and createdBy fields
versioningfalseEnables version history on save (shorthand for versions: { enabled: true })
softDeletefalseMove 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.

TypeTrait 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.

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.

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

OptionTypeDefaultDescription
mimeTypesstring[]--Restrict accepted MIME types (e.g. ["image/*"])
maxFileSizenumber--Maximum file size in bytes
imageSizesImageSizeConfig[]--Auto-generated image variants on upload
focalPointbooleantrue when imageSizes definedShow focal point selector
cropbooleantrueEnable crop tool
formatOptionsFormatOptions--Output format conversion
resizeOptionsobject--Resize original (width, height, fit)
adminThumbnailstring | function--Image size name or custom function for admin thumbnails
bulkUploadbooleantrueEnable bulk upload from list view
displayPreviewbooleantrueShow preview in upload fields
filesRequiredOnCreatebooleantrueRequire file on document creation
pasteURLboolean | object--Allow pasting URLs to fetch files
storageAdapterstringglobal adapterOverride the storage adapter
prefixstring--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.

OptionTypeDefaultDescription
url*string--URL template with {fieldName} placeholders
enabledbooleantrueWhether 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.

OptionTypeDefaultDescription
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.

OptionTypeDefaultDescription
enabled*boolean--Whether localization is active
locales*string[]--Available locale codes (e.g. ["en", "es"])
defaultLocale*string--Default locale stored at the top level
localizedFieldsstring[]all fieldsSpecific 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
OptionTypeDefaultDescription
parentFieldstring"parent"Field name holding the parent document reference
maxDepthnumber10Maximum 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(),
  },
});
HookArgumentsDescription
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(),
  },
});
OptionTypeDefaultDescription
enabledbooleantrueWhether auto-save is active for this collection
debounceMsnumber3000Milliseconds 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 afterChange hooks 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

RangeUse case
1000–2000 msFast-paced collaborative editing. More frequent saves, higher server mutation rate.
3000 msDefault — 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 }),
  },
});
OptionTypeDefaultDescription
fieldstring"status"Field name for the status value
valuesstring[]["draft", "published", "scheduled", "trashed"]Available status options
indexstring"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 }),
  },
});
OptionTypeDefaultDescription
statusstring"status"Status field name
updatedAtstring"updatedAt"Timestamp field name
updatedBystring"updatedBy"Updated-by audit field name
createdBystring"createdBy"Created-by audit field name
slugstring--Slug field name (enables by_slug index when set)

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(),
  },
});
OptionTypeDefaultDescription
groupstring--Group label in picker dialogs
thumbnailstring--Static thumbnail image URL
iconstring--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(),
  },
});
OptionTypeDefaultDescription
enabled*boolean--Whether version history is active
maxVersionsnumber25Maximum 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

OptionTypeDefaultDescription
blockBehaviorboolean | VextroBlockBehaviorConfig--Opt this collection in as a block type
blockBehavior.slugstringcollection slugOverride the block slug
blockBehavior.labelstringcollection labelOverride the block label in the picker
blockBehavior.pickerVextroBlockPickercollection pickerPicker 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.

Previous
Coming from Directus
Next
Globals