Fields
Blocks Field
The blocks field enables flexible, heterogeneous content sections. Each block type is defined with its own fields and stored in a separate Convex table. The parent document holds an ordered array of references. This allows different block types (hero, CTA, gallery) to coexist in a single list.
Config Options
| Option | Type | Default | Description |
|---|---|---|---|
definitions | VextroBlockTypeSpecifier[] | required | Block type definitions or blockRef() refs (first argument) |
required | boolean | false | Whether at least one block is required |
includeOrder | boolean | true | Store an order number on each reference |
children | VextroBlockTypeSpecifier[] | -- | Block types allowed as children (on defineVextroBlock). Auto-injects a children field on the block table. |
maxDepth | number | 3 | Maximum nesting depth when children is set |
options | VextroFieldsInput | -- | Block-specific options (on defineVextroBlock). Rendered in an Options panel. |
mainAppBlocks | Array<{ slug, tableName, label, picker? }> | -- | App-level block references from the main Convex namespace (on f.blocks() second argument). Enables mixing Vextro component blocks with app-managed block tables. |
Example Usage
Block types are defined separately with defineVextroBlock, then passed to f.blocks().
import { f, defineVextroBlock, defineVextroCollection } from "vextro";
const hero = defineVextroBlock({
slug: "hero",
label: "Hero",
tableName: "heroBlocks",
fields: {
heading: f.text({ required: true }),
backgroundImage: f.image({ relationTo: "media" }),
},
});
const cta = defineVextroBlock({
slug: "cta",
label: "CTA",
tableName: "ctaBlocks",
fields: { name: f.text({ required: true }), body: f.richText() },
});
export const pages = defineVextroCollection({
slug: "pages",
label: "Pages",
collectionType: "content",
tableName: "pages",
fields: {
title: f.text({ required: true }),
content: f.blocks([hero, cta]),
},
}); Mixing app-level blocks
Use mainAppBlocks on the f.blocks() second argument to include block types that live in your app's Convex namespace alongside Vextro component blocks:
export const pages = defineVextroCollection({
slug: "pages",
label: "Pages",
collectionType: "content",
tableName: "pages",
fields: {
title: f.text({ required: true }),
content: f.blocks([hero, cta], {
mainAppBlocks: [
{
slug: "productCard",
tableName: "productCards",
label: "Product Card",
picker: { group: "Commerce" },
},
],
}),
},
}); App-level blocks are stored with componentSource: "main" in the reference array, while Vextro component blocks use componentSource: "vextroBlocks". Slugs must be unique across both sources.
Storage Model
Blocks use a separate table architecture. Each block type has its own Convex table, and the parent document stores an ordered array of typed references. Each block table automatically includes status, updatedAt, updatedBy, and usedIn system fields.
{
"content": [
{ "blockType": "hero", "blockId": "heroBlocks:abc123", "order": 0 },
{ "blockType": "cta", "blockId": "ctaBlocks:def456", "order": 1 }
]
} Admin Options
Block definitions support a picker option with group, icon, and thumbnail for the admin insert dialog. All common admin options (label, description, condition, sidebar, viewTransition) are also supported. See Overview for details.
Picker Configuration
Control how blocks appear in the insert dialog with the picker option:
const testimonial = defineVextroBlock({
slug: "testimonial",
label: "Testimonial",
tableName: "testimonialBlocks",
description: "Customer quote with attribution",
group: "Social Proof",
picker: {
group: "Social Proof",
thumbnail: "/admin/block-thumbs/testimonial.png",
icon: "ChatCircle",
},
fields: {
quote: f.textarea({ required: true }),
author: f.text({ required: true }),
role: f.text(),
avatar: f.image({ relationTo: "media" }),
},
}); | Picker Option | Type | Description |
|---|---|---|
picker.group | string | Category heading in the block picker dialog |
picker.thumbnail | string | Preview image URL shown in the picker tile |
picker.icon | string | Phosphor icon name shown alongside the label |
System Fields
Each block table automatically includes these system fields. They are managed internally and do not need to be defined in your block's fields.
| Field | Type | Description |
|---|---|---|
status | string | Block lifecycle status (draft, published, etc.) |
updatedAt | number | Timestamp of last modification |
updatedBy | string | ID or name of the user who last modified the block |
usedIn | array | Tracks which documents reference this block (auto-synced on save) |
thumbnail | string | Optional preview thumbnail URL |
_blockOptions | object | Common block options (spacing, appearance, etc.). Only injected when commonOptions is configured on createVextroBlocksSchema() or createVextroSchema(). |
_options | any | Per-instance block options. Only injected when the block definition includes an options config. Rendered in a separate Options panel in the admin editor. |
_stylePreset | Id | Reference to a style presets table entry. Only injected when commonOptions.presets is configured. |
The usedIn array is automatically maintained by syncBlockUsageForDocument() whenever a parent document is created, updated, or deleted.
Multi-Use Protection
Because a single block can be referenced by multiple documents, the admin UI includes safeguards:
- "Referenced By" sidebar — when editing a block, the sidebar lists all documents that use it as clickable links.
- Save confirmation — when a block is used in 2 or more documents, saving triggers a confirmation dialog listing every affected document. The editor must explicitly click "Save Anyway" to proceed.
See Reference Tracking for details.
Built-in Primitives
Vextro ships ready-to-use block configs importable from vextro/blocks. Use them directly or spread into custom configs:
import { defineVextroBlock } from "vextro/blocks";
import { containerBlock, richTextBlock, imageBlockRef } from "vextro/blocks";
const richText = defineVextroBlock(richTextBlock);
const container = defineVextroBlock({
...containerBlock,
children: [richText, imageBlockRef],
}); Available primitives: richTextBlock, heroBlock, imageBlock, spacerBlock, dividerBlock, containerBlock. Each also has a matching blockRef export (e.g., richTextBlockRef).
Nested Blocks
Blocks can contain other blocks by declaring children on defineVextroBlock(). This enables page-builder patterns like Container → Row → Card. Use blockRef() for circular references.
import { f, defineVextroBlock, blockRef } from "vextro";
import { containerBlock, richTextBlockRef } from "vextro/blocks";
const card = defineVextroBlock({
slug: "card",
label: "Card",
tableName: "cardBlocks",
group: "Layout",
fields: {
heading: f.text({ required: true }),
body: f.richText(),
},
});
const row = defineVextroBlock({
slug: "row",
label: "Row",
tableName: "rowBlocks",
group: "Layout",
fields: {
columns: f.select({ options: ["2", "3", "4"], defaultValue: "2" }),
},
children: [
card,
blockRef({ slug: "container", label: "Container", tableName: "containerBlocks" }),
],
}); Use blockRef() when blocks reference each other circularly. A ref only needs slug, label, and tableName. Use full definitions for createVextroBlocksSchema() and buildAdminDefinitions().
Inline Nested Editor
When container blocks define children, the admin UI renders an inline nested editor instead of the flat block list. Container blocks display their children directly in the configured layout (stack, row, or grid). Editors can drag blocks across nesting levels, add/remove at any level, collapse/expand containers, and cut/copy/paste blocks with their children. A breadcrumb shows the current nesting path.
In the admin UI, each nested block item also shows an "Edit" link that opens the block's edit page in a new tab, preserving unsaved work on the parent document. See Nested Blocks for the full pattern and usage rules.
Complex Block Examples
Blocks can contain any field type, including groups, conditions, and relationships:
const featureCard = defineVextroBlock({
slug: "featureCard",
label: "Feature Card",
tableName: "featureCardBlocks",
group: "Content",
picker: { group: "Content", icon: "Cards" },
fields: {
heading: f.text({ required: true }),
body: f.richText(),
icon: f.relationship({
relationTo: "media",
displayField: "filename",
}),
link: f.group({
fields: {
label: f.text({ required: true }),
url: f.url({ required: true }),
openInNewTab: f.checkbox({ defaultValue: false }),
},
}),
variant: f.select({
options: ["default", "highlighted", "minimal"],
defaultValue: "default",
}),
customColor: f.text({
condition: { field: "variant", equals: "highlighted" },
description: "Hex color code for the highlight accent",
}),
},
});