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:

  • stack
  • flex
  • grid -- 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

OptionTypeDefaultDescription
slugstringtableNameUnique block type identifier.
labelstringrequiredDisplay name in the admin UI.
tableNamestringrequiredConvex table for this block type.
fieldsVextroFieldsInputrequiredField definitions using f builders.
descriptionstring--Description shown in the block picker.
groupstring"Blocks"Sidebar group and picker category.
collectionTypestring"content"Semantic label for sidebar grouping. Built-in values ("content", "config", "system") map to trait presets, but any custom string is accepted.
pickerVextroBlockPicker--Picker thumbnail, icon, and group.
sidebarbooleanfalseWhether block type appears in the admin sidebar.
optionsVextroFieldsInput--Per-instance block options (separate from main fields). Rendered in a dedicated Options panel. Stored in the _options system field.
responsiveOptionsstring[]--Block-specific option field names that can be overridden per breakpoint. Only meaningful when options is also set. See Responsive overrides.
scopeVextroCollectionScope--Multi-tenant scope config. Set field (the scope field name) and optional type (e.g., "region", "tenant").
listConfigVextroListConfig--List view config: columns, searchableFields, defaultSort, defaultSortDirection, defaultLayout ("list" / "grid" / "tree"), folderFields.
accessVextroAccessConfig--Access control with read, create, update, delete permission strings.
childrenVextroBlockTypeSpecifier[]--Block types allowed as children. Auto-injects a children blocks field. See Nested Blocks.
maxDepthnumber3Maximum nesting depth for children (only meaningful when children is set).
statusValuesstring[]--Custom status values for the block's status field.
fieldNamesVextroBlockFieldNames--Override default system field names: status, updatedAt, updatedBy, usedIn, thumbnail. See Reference Tracking — Custom field names.
adminFieldsRecord<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" }),
  },
});
OptionTypeDescription
descriptionstringTooltip or subtitle shown below the label in the picker.
groupstringSidebar group and picker category (default: "Blocks").
picker.groupstringOverride the picker category heading.
picker.thumbnailstringPreview image URL for the picker tile.
picker.iconstringPhosphor 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.

FieldTypeDescription
statusstringBlock status (e.g., "draft", "published"). Follows the parent collection's workflow traits.
updatedAtnumberUnix timestamp (ms) of the last update.
updatedBystringID of the user who last modified the block.
usedInarrayReferences to all documents using this block. Each entry contains collection, tableName, documentId, and field.
thumbnailstringAuto-generated preview image URL for the block picker and list views.
_blockOptionsobjectCommon option values (injected when commonOptions is configured).
_optionsobjectBlock-specific option values (injected when options is set on the definition).
_responsiveOverridesobjectPer-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.

PrimitiveSlugTable NameDescription
richTextBlockrichTextrichTextBlocksRich text content block with a TipTap editor field
heroBlockheroheroBlocksHero section with heading, subheading, image, CTA, and variant options (centered, split, overlay)
imageBlockimageimageBlocksImage display with caption, alt text, and variant options (full, contained, rounded)
spacerBlockspacerspacerBlocksVertical spacing with height variants (sm, md, lg, xl)
dividerBlockdividerdividerBlocksHorizontal divider with style variants (solid, dashed, gradient)
containerBlockcontainercontainerBlocksLayout 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.

Previous
Overview