Configuration
Virtual Collections
Overview
Virtual collections let you present multiple logical content types from a single shared Convex table. Instead of creating a separate table for each collection, you store all documents in one table and use a discriminator field (like collectionSlug) to filter them. Vextro handles the filtering automatically based on metadata configuration.
This is useful when you need to add or rename content types without schema migrations, or when you want a flexible "page builder" model where many content types share the same document structure.
How it works
A virtual collection is configured with a collectionField and collectionValue in its list config. When Vextro queries documents for this collection, it filters by collectionField = collectionValue using the specified index.
// All virtual collections share the "content_documents" table
// Each is filtered by collectionSlug = "<collection-slug>"
const pagesDefinition = {
slug: "pages",
label: "Pages",
collectionType: "content",
tableName: "content_documents",
listConfig: {
collectionField: "collectionSlug",
collectionValue: "pages",
collectionIndex: "by_collectionSlug",
collectionStatusIndex: "by_collectionSlug_and_status",
statusField: "status",
statusValues: ["draft", "published", "scheduled", "trashed"],
},
};
const announcementsDefinition = {
slug: "announcements",
label: "Announcements",
collectionType: "content",
tableName: "content_documents",
listConfig: {
collectionField: "collectionSlug",
collectionValue: "announcements",
collectionIndex: "by_collectionSlug",
collectionStatusIndex: "by_collectionSlug_and_status",
statusField: "status",
statusValues: ["draft", "published"],
},
}; Schema setup
The shared table needs composite indexes that include the collection discriminator field.
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
content_documents: defineTable({
collectionSlug: v.string(),
title: v.string(),
slug: v.string(),
body: v.optional(v.any()),
status: v.string(),
updatedAt: v.number(),
updatedBy: v.optional(v.string()),
})
.index("by_collectionSlug", ["collectionSlug"])
.index("by_collectionSlug_and_status", ["collectionSlug", "status"])
.index("by_slug", ["slug"]),
}); Index design matters
Virtual collections rely on indexed queries. Make sure your shared table has a composite index that includes both the collection discriminator field and the status field. Without collectionStatusIndex, status filtering falls back to a full scan within the collection partition.
Vextro behavior
When collectionField is present in a collection's list config, Vextro automatically:
- Filters list results by
collectionField = collectionValue - Injects the discriminator field value on document create
- Enforces the discriminator field on update and delete operations
- Uses
collectionStatusIndexwhen filtering by status in list views - Hides the discriminator field from the document editor
Virtual vs. schema collections
Choose the approach that fits your use case.
Schema collections (one table per type)
- Best query performance with direct table access and simple indexes
- Strong type safety with schema-defined document shapes
- Requires a schema change for every new collection
- Recommended for stable, well-defined content types
Virtual collections (shared table)
- Fast iteration -- add new collections via metadata without schema changes
- Flexible document structure shared across multiple types
- Composite index overhead for collection + status queries
- Weaker type safety since the document shape is shared
- Recommended for rapid prototyping or user-defined content types
// You can mix both approaches in the same project
const collectionDefinitions = buildAdminDefinitions({
// Schema collections with their own tables
collections: [posts, media, users],
// Virtual collections sharing a table
extra: [pagesDefinition, announcementsDefinition, faqDefinition],
}); Read-only views of existing tables
Virtual collections can also serve as read-only admin views of Convex tables that are managed elsewhere -- user tables, analytics events, or audit logs. Set the access config to restrict mutations.
const userManagement = {
slug: "users",
label: "Users",
description: "View and manage user accounts",
collectionType: "system",
tableName: "users",
listConfig: {
columns: ["displayName", "email", "status", "lastLoginAt"],
searchableFields: ["displayName", "email"],
defaultSort: "lastLoginAt",
defaultSortDirection: "desc",
},
access: {
read: "admin:users:read",
create: "admin:users:write",
update: "admin:users:write",
delete: "admin:users:admin",
},
}; System collections
Setting collectionType: "system" places the collection in a separate sidebar group and signals to the admin UI that these are infrastructure records rather than editorial content.
Use cases
Analytics dashboard
Expose an analytics events table as a read-only collection for internal review.
const analyticsEvents = {
slug: "analytics-events",
label: "Analytics Events",
collectionType: "system",
tableName: "analytics_events",
listConfig: {
columns: ["eventType", "userId", "timestamp", "metadata"],
defaultSort: "timestamp",
defaultSortDirection: "desc",
},
access: {
read: "analytics:read",
},
}; Multi-tenant content
Use the scope config to partition virtual collections by tenant or region. See Scopes & Multi-Tenancy for the full setup guide including user assignments, admin callbacks, and UI configuration.
const regionalPages = {
slug: "regional-pages",
label: "Regional Pages",
collectionType: "content",
tableName: "content_documents",
listConfig: {
collectionField: "collectionSlug",
collectionValue: "regional-pages",
collectionIndex: "by_collectionSlug",
collectionStatusIndex: "by_collectionSlug_and_status",
statusField: "status",
statusValues: ["draft", "published"],
},
scope: {
field: "regionSlug",
type: "region",
},
}; Creating virtual collections from the admin UI
Vextro exposes mutations for creating and updating collection metadata at runtime. This enables admin users to define new virtual collections without code changes, as long as the backing shared table already exists.
// These are exposed by the admin module
export const createCollection = admin.createCollection;
export const updateCollection = admin.updateCollection; New virtual collections appear in the sidebar immediately after creation and use the same list and editor UI as code-defined collections.