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 collectionStatusIndex when 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.

Previous
Globals