Blocks
Defining Blocks
Block types are defined with defineVextroBlock() using the f field builder. Each definition produces a Convex table schema and an admin UI configuration. The slug defaults to tableName when omitted.
Built-in Layout block
Vextro auto-injects a built-in Layout block during schema generation. Its slug (vextroLayout) and table (vextroLayoutBlocks) are reserved and cannot be used by app-defined blocks.
Built-in Layout block editor behavior
The built-in vextroLayout block ships with a dedicated editor body and options panel in the admin UI. Editors can:
- add child blocks from inline insertion points
- reorder children with keyboard-accessible block actions
- collapse and expand child trees (including mixed-state controls)
- configure layout mode options from the block options panel
Current admin body layout modes are:
stackflexgrid-- configurable columns (1-12) with cell-based block placement, arrow-key navigation, and "Move to cell" controls
When switching between modes, Vextro preserves mode-specific values so editors can toggle modes without losing prior configuration.
Basic definition
import { f, defineVextroBlock } from "vextro";
export const hero = defineVextroBlock({
label: "Hero",
tableName: "heroBlocks",
fields: {
heading: f.text({ required: true }),
subheading: f.textarea(),
backgroundImage: f.image({ relationTo: "media" }),
},
});
export const cta = defineVextroBlock({
slug: "cta", // explicit slug differs from tableName
label: "Call to Action",
tableName: "ctaBlocks",
group: "Marketing",
fields: {
heading: f.text({ required: true }),
buttonLabel: f.text({ required: true }),
buttonUrl: f.url({ required: true }),
},
}); When slug is omitted, it defaults to the tableName value. In the first example above, the slug is "heroBlocks". In the second, the explicit slug "cta" is used instead of "ctaBlocks".
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
slug | string | tableName | Unique block type identifier. |
label | string | required | Display name in the admin UI. |
tableName | string | required | Convex table for this block type. |
fields | VextroFieldsInput | required | Field definitions using f builders. |
description | string | -- | Description shown in the block picker. |
group | string | "Blocks" | Sidebar group and picker category. |
collectionType | string | "content" | Semantic label for sidebar grouping. Built-in values ("content", "config", "system") map to trait presets, but any custom string is accepted. |
picker | VextroBlockPicker | -- | Picker thumbnail, icon, and group. |
sidebar | boolean | false | Whether block type appears in the admin sidebar. |
options | VextroFieldsInput | -- | Per-instance block options (separate from main fields). Rendered in a dedicated Options panel. Stored in the _options system field. |
responsiveOptions | string[] | -- | Block-specific option field names that can be overridden per breakpoint. Only meaningful when options is also set. See Responsive overrides. |
scope | VextroCollectionScope | -- | Multi-tenant scope config. Set field (the scope field name) and optional type (e.g., "region", "tenant"). |
listConfig | VextroListConfig | -- | List view config: columns, searchableFields, defaultSort, defaultSortDirection, defaultLayout ("list" / "grid" / "tree"), folderFields. |
access | VextroAccessConfig | -- | Access control with read, create, update, delete permission strings. |
children | VextroBlockTypeSpecifier[] | -- | Block types allowed as children. Auto-injects a children blocks field. See Nested Blocks. |
maxDepth | number | 3 | Maximum nesting depth for children (only meaningful when children is set). |
statusValues | string[] | -- | Custom status values for the block's status field. |
fieldNames | VextroBlockFieldNames | -- | Override default system field names: status, updatedAt, updatedBy, usedIn, thumbnail. See Reference Tracking — Custom field names. |
adminFields | Record<string, VextroAdminFieldConfig> | -- | Admin UI field definitions for inline editing. When provided, these field configs drive the block's inline edit form inside the block card. Supports field type overrides, labels, validation hints, and ordering. |
Circular references
When block types reference each other (e.g., Container → Row → Container), use blockRef() to create lightweight forward references instead of full definitions. A ref only needs slug, label, and tableName. See Nested Blocks & blockRef for details.
Picker configuration
Customize how a block type appears in the admin picker dialog:
const testimonial = defineVextroBlock({
label: "Testimonial",
tableName: "testimonialBlocks",
description: "Customer quote with avatar and attribution",
group: "Social Proof",
picker: {
group: "Social Proof",
thumbnail: "/admin/block-thumbs/testimonial.png",
icon: "Quotes",
},
fields: {
quote: f.textarea({ required: true }),
authorName: f.text({ required: true }),
authorRole: f.text(),
avatar: f.image({ relationTo: "media" }),
},
}); | Option | Type | Description |
|---|---|---|
description | string | Tooltip or subtitle shown below the label in the picker. |
group | string | Sidebar group and picker category (default: "Blocks"). |
picker.group | string | Override the picker category heading. |
picker.thumbnail | string | Preview image URL for the picker tile. |
picker.icon | string | Phosphor icon name for the picker label. |
Using blocks in collections
Reference block types in a collection field with f.blocks():
import { f, defineVextroCollection } from "vextro";
import { hero, cta, testimonial } from "./blocks";
export const pages = defineVextroCollection({
label: "Pages",
collectionType: "content",
tableName: "pages",
fields: {
title: f.text({ required: true }),
content: f.blocks([hero, cta, testimonial]),
sidebar: f.blocks([cta]),
},
}); The stored value is an array of BlockEntry values. New blocks added through the editor are inline by default; editors can promote them to shared blocks later. A mix of inline and reference entries in the same field is supported.
[
{ "kind": "inline", "blockType": "heroBlocks", "blockId": "local_abc", "order": 0, "data": { "heading": "Welcome" } },
{ "kind": "reference", "blockType": "cta", "blockId": "def456", "componentSource": "vextroBlocks", "order": 1 }
] System fields
Every block table automatically includes these system fields. You do not define them -- Vextro injects them during schema generation.
| Field | Type | Description |
|---|---|---|
status | string | Block status (e.g., "draft", "published"). Follows the parent collection's workflow traits. |
updatedAt | number | Unix timestamp (ms) of the last update. |
updatedBy | string | ID of the user who last modified the block. |
usedIn | array | References to all documents using this block. Each entry contains collection, tableName, documentId, and field. |
thumbnail | string | Auto-generated preview image URL for the block picker and list views. |
_blockOptions | object | Common option values (injected when commonOptions is configured). |
_options | object | Block-specific option values (injected when options is set on the definition). |
_responsiveOverrides | object | Per-breakpoint overrides (injected when responsive or responsiveOptions is configured). Shape: { [breakpoint]: { [field]: value } }. |
Responsive overrides
Block-specific options can be made responsive by listing field names in responsiveOptions. This works alongside the common-level responsive config described in Block Schema Generation — Responsive overrides.
export const hero = defineVextroBlock({
label: "Hero",
tableName: "heroBlocks",
fields: {
heading: f.text({ required: true }),
backgroundImage: f.image({ relationTo: "media" }),
},
options: {
height: f.select({
options: ["auto", "half", "full"],
defaultValue: "auto",
}),
textAlign: f.select({
options: ["left", "center", "right"],
defaultValue: "center",
}),
overlayOpacity: f.number({ min: 0, max: 100, defaultValue: 50 }),
},
responsiveOptions: ["height", "textAlign"],
}); In this example, height and textAlign show breakpoint tabs in the block options panel so editors can set different values per screen size. overlayOpacity always uses its base value.
When responsiveOptions is set, Vextro auto-injects a _responsiveOverrides system field on the block table (the same field used by common-level responsive config). Per-breakpoint values are stored as { [breakpoint]: { [fieldName]: value } }.
Common + block responsive
Common-level responsive.fields and block-level responsiveOptions are independent. A block can have both — common fields use the shared breakpoints from commonOptions.responsive, and block-specific fields use the same breakpoints. Both write to the same _responsiveOverrides field.
Built-in primitives
Vextro ships six ready-to-use block config objects importable from vextro/blocks. Each is a VextroBlockInput that can be passed directly to defineVextroBlock() or spread into a custom config.
| Primitive | Slug | Table Name | Description |
|---|---|---|---|
richTextBlock | richText | richTextBlocks | Rich text content block with a TipTap editor field |
heroBlock | hero | heroBlocks | Hero section with heading, subheading, image, CTA, and variant options (centered, split, overlay) |
imageBlock | image | imageBlocks | Image display with caption, alt text, and variant options (full, contained, rounded) |
spacerBlock | spacer | spacerBlocks | Vertical spacing with height variants (sm, md, lg, xl) |
dividerBlock | divider | dividerBlocks | Horizontal divider with style variants (solid, dashed, gradient) |
containerBlock | container | containerBlocks | Layout container with columns, gap, alignment, padding, margin, maxWidth, backgroundColor, and a responsive JSON field for per-breakpoint overrides |
Each primitive also has a corresponding blockRef export (e.g., richTextBlockRef, heroBlockRef) for use in children arrays without importing the full definition.
Usage
import { defineVextroBlock } from "vextro/blocks";
import { richTextBlock, heroBlock, imageBlock } from "vextro/blocks";
import { createVextroBlocksSchema } from "vextro";
// Use directly
const richText = defineVextroBlock(richTextBlock);
const hero = defineVextroBlock(heroBlock);
const image = defineVextroBlock(imageBlock);
const { tables, blocksField } = createVextroBlocksSchema({
definitions: [richText, hero, image],
}); Extending primitives
Spread a primitive into a custom config to override or add properties:
import { defineVextroBlock } from "vextro/blocks";
import { containerBlock, richTextBlockRef, imageBlockRef } from "vextro/blocks";
const container = defineVextroBlock({
...containerBlock,
children: [richTextBlockRef, imageBlockRef],
maxDepth: 2,
}); Container block responsive JSON
The containerBlock includes a responsive JSON field where editors can store per-breakpoint layout overrides. The field accepts an object with breakpoint keys (sm, md, lg, xl) containing any layout property overrides:
{
"sm": { "columns": 1, "gap": 2 },
"md": { "columns": 2, "gap": 4 },
"lg": { "columns": 3, "gap": 6 }
} This is separate from the _responsiveOverrides system field used by commonOptions.responsive and responsiveOptions. The container's responsive JSON field is a content field that your frontend rendering code reads directly.
Complex example
A realistic featureCard block combining groups, conditional fields, and a relationship:
import { f, defineVextroBlock } from "vextro";
export const featureCard = defineVextroBlock({
label: "Feature Card",
tableName: "featureCardBlocks",
group: "Content",
description: "Highlighted feature with icon, description, and optional link",
picker: {
group: "Content",
icon: "Star",
thumbnail: "/admin/block-thumbs/feature-card.png",
},
fields: {
icon: f.select({
options: ["rocket", "shield", "zap", "heart", "globe"],
required: true,
}),
heading: f.text({ required: true, listColumn: true }),
description: f.textarea({
required: true,
maxCharacters: 200,
}),
variant: f.select({
options: ["default", "outlined", "filled"],
defaultValue: "default",
}),
accentColor: f.color({
condition: { field: "variant", notEquals: "default" },
description: "Accent color for the card border or background",
}),
link: f.group({
fields: {
type: f.select({
options: ["internal", "external"],
defaultValue: "internal",
}),
page: f.relationship({
relationTo: "pages",
condition: { field: "link.type", equals: "internal" },
}),
url: f.url({
condition: { field: "link.type", equals: "external" },
}),
label: f.text({ required: true }),
openInNewTab: f.checkbox(),
},
}),
image: f.image({ relationTo: "media" }),
},
}); Options vs. fields
The options property defines per-instance settings that appear in a separate section of the block editor, while fields defines the main content area. Use options for layout hints, visibility toggles, or spacing values that apply each time the block is placed -- not for the block's primary content.