blocks field
A blocks() field stores an ordered, heterogeneous list of typed block objects.
Each block carries a blockType discriminant (e.g. "heading", "image",
"cta") that identifies which block definition it matches, plus an id for
stable React reconciliation and an optional blockName for user-editable labels.
Common uses: page builders, hero sections, feature grids, FAQ accordions, rich-media rows, and any modular content layout where editors mix and reorder different content types.
The admin panel renders a dynamic collapsible list with a searchable block-type picker dialog. Each block item shows an inline name input, a type badge, and sub-field inputs for the block’s own fields.
Defining blocks
Section titled “Defining blocks”Before using blocks(), define each block type with defineBlock():
import { defineBlock, text, select, number } from "@vexcms/core";
const headingBlock = defineBlock({ slug: "heading", label: "Heading", admin: { icon: "Heading" }, fields: { level: select({ options: [ { label: "H1", value: "h1" }, { label: "H2", value: "h2" }, { label: "H3", value: "h3" }, ], }), text: text({ required: true }), },});
const imageBlock = defineBlock({ slug: "image", label: "Image", admin: { icon: "Image" }, fields: { url: text({ required: true, admin: { placeholder: "https://..." } }), alt: text({ required: true }), width: number(), },});Three keys are injected automatically on every block item and must not
appear in the fields object: blockType, blockName, and id.
Config options
Section titled “Config options”| Option | Type | Default | Description |
|--------|------|---------|-------------|
| label | string | "" | Display label in the admin panel. Inferred from the field key by defineCollection when left empty. |
| blocks | BlockConfig[] | — | Required. Array of block definitions created via defineBlock(). |
| interfaceName | string | — | Optional TypeScript union alias name. When set, emits a named union type (e.g. PageBlock[]) instead of an inline union. |
| required | boolean | false | When false, the field is wrapped in v.optional(…) in the schema. |
| defaultValue | Record<string, unknown>[] | [] | Pre-filled array shown in the admin form when creating a new document. |
| min | number \| [number, string] | — | Minimum number of block items. Bare number, or a [value, customError] tuple for a custom error message. Zod-enforced; does not affect the generated Convex schema. |
| max | number \| [number, string] | — | Maximum number of block items. Bare number, or a [value, customError] tuple. When reached, the “Add” button is disabled in the admin UI. |
| labels.singular | string | "Block" | Label for a single item in add/remove controls (e.g. “Add Section”). |
| labels.plural | string | "Blocks" | Label shown in the field header and empty state. |
| description | string | — | Helper text shown below the field input. |
| admin.hidden | boolean | false | Hides the field from the admin edit form entirely. |
| admin.readOnly | boolean | false | Renders add/remove controls as non-interactive. |
| admin.position | "main" \| "sidebar" | "main" | Column in the admin edit layout where this field appears. |
| admin.width | string | "100%" | CSS width of the field in the edit form (e.g. "50%", "240px"). Meaningful as a child of row(); below a narrow form column every field renders full width regardless. |
| admin.cellAlignment | "left" \| "center" \| "right" | "left" | Alignment of the block count in the list-view table cell. |
| admin.condition | (props: { data, siblingData }) => boolean | — | Shows the field only while this returns true. A throwing condition counts as met. Hidden fields skip required/validate on the server too. See the Conditional fields & masks guide. |
Schema output
Section titled “Schema output”// Optional blocks array (default — required: false)body: v.optional(v.array(v.union( v.object({ blockType: v.literal("heading"), blockName: v.optional(v.string()), id: v.string(), level: v.optional(v.string()), text: v.optional(v.string()) }), v.object({ blockType: v.literal("image"), blockName: v.optional(v.string()), id: v.string(), url: v.optional(v.string()), alt: v.optional(v.string()), width: v.optional(v.number()) }),)))
// Required blocks array with min/maxbody: v.array(v.union( v.object({ blockType: v.literal("heading"), blockName: v.optional(v.string()), id: v.string(), level: v.optional(v.string()), text: v.optional(v.string()) }), v.object({ blockType: v.literal("image"), blockName: v.optional(v.string()), id: v.string(), url: v.optional(v.string()), alt: v.optional(v.string()), width: v.optional(v.number()) }),)).min(1).max(10)
// Single block type (no union — simpler schema)hero: v.array(v.object({ blockType: v.literal("hero"), blockName: v.optional(v.string()), id: v.string(), title: v.optional(v.string()), subtitle: v.optional(v.string()) }))import { defineCollection, blocks, defineBlock, text, select, number } from "@vexcms/core";
// Define reusable block typesconst headingBlock = defineBlock({ slug: "heading", label: "Heading", admin: { icon: "Heading" }, fields: { level: select({ options: [{ label: "H1", value: "h1" }, { label: "H2", value: "h2" }] }), text: text({ required: true }), },});
const paragraphBlock = defineBlock({ slug: "paragraph", label: "Paragraph", admin: { icon: "AlignLeft" }, fields: { content: text({ required: true }) },});
const spacerBlock = defineBlock({ slug: "spacer", label: "Spacer", admin: { icon: "MoveVertical" }, fields: { height: number({ defaultValue: 24 }) },});
const pages = defineCollection({ slug: "pages", fields: { // Basic blocks field with multiple block types body: blocks({ blocks: [headingBlock, paragraphBlock, spacerBlock], }),
// Named union alias with min/max constraints sections: blocks({ label: "Page Sections", interfaceName: "PageSection", blocks: [headingBlock, paragraphBlock], min: [1, "At least one section is required."], max: 20, labels: { singular: "Section", plural: "Sections" }, }),
// Pre-filled default blocks hero: blocks({ blocks: [headingBlock], defaultValue: [ { blockType: "heading", id: "init-hero", level: "h1", text: "Welcome" }, ], }), },});Admin UI
Section titled “Admin UI”The blocks field renders as a dynamic collapsible list. An empty state shows
a placeholder icon with “No [plural] yet.” text. Each block item displays a
drag handle, order number, type badge, and an inline blockName input for
renaming. Sub-fields for the block are rendered when the item is expanded,
using the same input components as standalone fields.
admin.icon (here and on collections, globals, and live-preview breakpoints) takes a
canonical PascalCase Lucide name, typed as IconName. The name
set comes from vex generate; before the types are generated, any string is accepted.
A searchable dialog handles block-type selection when multiple block types
are configured; a single block type skips the dialog and adds directly.
The “Add [singular]” button is disabled when the max constraint is reached.
Validation errors from min/max appear below the field after a submission
attempt or when the form is touched.
Header-level actions — Copy all, Paste, Replace all, Collapse all, Expand all — sit above the list once it holds at least one block. Each block item carries its own action menu: Duplicate, Move up/down, Insert above/below, Copy, Paste above/below, and Remove. A copied block (or the whole list via Copy all) goes onto a shared block clipboard that works across different blocks fields, documents, and collections — pasting coerces the clipboard data to the target block definition’s fields and drops any unknown keys.
TypeScript output
Section titled “TypeScript output”When you use defineBlock() and blocks(), VexCMS generates TypeScript types
automatically:
// Generated interface for the blocks fieldexport type BodyBlock = HeadingBlock | ParagraphBlock | SpacerBlock;
// Generated document interfaceexport interface PagesDocument extends VexDocument { _id: Id<"pages">; body: BodyBlock[]; // ...other fields}
// Each individual block typeexport type HeadingBlock = { blockType: "heading"; blockName?: string; id: string; level?: "h1" | "h2"; text?: string };export type ParagraphBlock = { blockType: "paragraph"; blockName?: string; id: string; content?: string };export type SpacerBlock = { blockType: "spacer"; blockName?: string; id: string; height?: number };Set interfaceName on the blocks field to use a named union alias instead of
the generated BodyBlock[] type.