Blocks

Block Schema Generation

createVextroBlocksSchema() generates Convex table definitions and a block type registry from your block definitions. It produces the tables, validators, and metadata that power the block system at runtime.

Built-in Layout injection

createVextroBlocksSchema() always injects a built-in Layout block definition:

  • slug: vextroLayout
  • table: vextroLayoutBlocks

You do not define this block manually. It is included automatically in:

  • definitions
  • tables
  • blocksField
  • componentConfig

The injected Layout block powers nested block composition in the admin editor and includes built-in layout options. The editor currently supports stack, flex, and grid body modes with keyboard-first workflows for adding, reordering, and validating nested children. Grid mode lets authors configure columns (1-12) and assign nested blocks to specific cells.

When editors switch between layout modes, Vextro retains dormant mode-specific values instead of clearing them. This allows safe mode toggling during content authoring.

Reserved identifiers

vextroLayout and vextroLayoutBlocks are reserved. Do not use them for app-defined block slugs, blockBehavior slugs, or schema table names when blocks are enabled.

Basic usage

Pass an array of block definitions and spread the returned tables into your Convex schema.

import { createVextroBlocksSchema } from "vextro";
import { defineSchema } from "convex/server";

const { tables, blocksField, registry } = createVextroBlocksSchema({
  definitions: [hero, cta, testimonial],
});

export default defineSchema({
  ...tables,
  // your other tables
});

Return value

PropertyTypeDescription
definitionsVextroBlockDefinition[]The resolved block definitions with defaults applied
registryVextroBlockRegistryRegistry object containing definitions, statusValues, statusValidator, tableNames, tableNameValidator, blockIdValidator, and optional commonOptionFields/commonOptionGroups
blocksFieldv.array(v.union(...))A Convex validator for block reference arrays
tablesRecord<string, TableDefinition>Table definitions to spread into defineSchema
presetTableTableDefinition | undefinedTable for block presets, if commonOptions.presets is configured
componentConfigobjectConfiguration passed to the Vextro Convex component for block routing
mainAppBlockDefsVextroBlockDefinition[]Block definitions that live in the main Convex namespace (not the component)

Arguments

createVextroBlocksSchema() accepts the following top-level options:

OptionTypeDefaultDescription
definitionsVextroBlockDefinition[]requiredBlock definitions to generate tables and registry for
includeOrderbooleantrueInclude an order field on each block reference
nameIndexFieldstring"name"Field name to create a by_<field> index on every block table. Set to "" to skip.
commonOptionsVextroCommonOptionsInput--Shared options applied to every block (spacing, appearance, etc.)
mainAppBlocksArray<{ slug, tableName, label, picker? }>--App-level block references that live in the main Convex namespace rather than the Vextro component

mainAppBlocks

When your application defines its own block tables outside of Vextro (e.g., blocks backed by regular collections), you can mix them into the same blocks field alongside Vextro component blocks. Pass mainAppBlocks to register these app-level block types so the schema validator and admin picker include them.

import { createVextroBlocksSchema } from "vextro";

const { tables, blocksField } = createVextroBlocksSchema({
  definitions: [hero, cta],
  mainAppBlocks: [
    {
      slug: "productCard",
      tableName: "productCards",
      label: "Product Card",
      picker: { group: "Commerce", icon: "ShoppingCart" },
    },
  ],
});

App-level blocks use componentSource: "main" in the stored reference array, while Vextro component blocks use componentSource: "vextroBlocks". Slugs must be unique across both sources.

nameIndexField

By default, every block table gets a by_name index on the name field. If your blocks use a different field for their display name, set nameIndexField to that field name. Pass an empty string to skip index generation.

const { tables } = createVextroBlocksSchema({
  definitions: [hero, cta],
  nameIndexField: "title", // creates by_title index instead of by_name
});

Common options

All blocks can share a set of fields that appear alongside block-specific fields. Configure these with commonOptions to add spacing, visibility, or other cross-cutting concerns.

const { tables } = createVextroBlocksSchema({
  definitions: [hero, cta],
  commonOptions: {
    fields: {
      spacing: f.select({
        options: ["none", "sm", "md", "lg"],
        defaultValue: "md",
      }),
      visible: f.checkbox({ defaultValue: true }),
    },
    groups: [
      { label: "Spacing", fields: ["spacing"] },
      { label: "Visibility", fields: ["visible"] },
    ],
    presets: {
      tableName: "blockPresets",
    },
  },
});

Common option fields are appended to every block's edit form under the configured group labels. The optional presets config creates a table for saving and reusing common option combinations.

Responsive overrides

Common options can be made responsive so editors can override values per breakpoint. Add a responsive config to commonOptions specifying which fields allow per-breakpoint overrides. The responsive property accepts a VextroResponsiveConfig object.

const { tables } = createVextroBlocksSchema({
  definitions: [hero, cta],
  commonOptions: {
    fields: {
      spacing: f.select({
        options: ["none", "sm", "md", "lg"],
        defaultValue: "md",
      }),
      alignment: f.select({
        options: ["left", "center", "right"],
        defaultValue: "left",
      }),
      visible: f.checkbox({ defaultValue: true }),
    },
    responsive: {
      breakpoints: ["base", "sm", "md", "lg", "xl"],
      fields: ["spacing", "alignment"],
    },
  },
});

VextroResponsiveConfig

OptionTypeDefaultDescription
breakpointsVextroResponsiveBreakpoint[]["base", "sm", "md", "lg", "xl"]Which breakpoints to show in the options panel. Values: "base", "sm", "md", "lg", "xl".
fieldsstring[]requiredCommon option field names that can be overridden per breakpoint.

The five available breakpoints ("base", "sm", "md", "lg", "xl") follow standard responsive design conventions. You can include a subset if your design system uses fewer breakpoints.

When responsive is configured, Vextro auto-injects a _responsiveOverrides system field on every block table. The field stores per-breakpoint overrides as { [breakpoint]: { [fieldName]: value } }.

Fields listed in responsive.fields show breakpoint tabs in the block options panel. Non-listed fields always use their base value regardless of the active breakpoint.

Consuming app rendering

The _responsiveOverrides data is stored on the block document. Your frontend rendering code is responsible for reading overrides and applying the correct value per breakpoint (e.g., using CSS media queries or a responsive utility). Vextro stores the data — your app applies it.

Using createVextroSchema

For projects that use collections, globals, and blocks together, createVextroSchema provides a single entry point that handles all schema generation. Import it from vextro/convex/schema:

import { createVextroSchema } from "vextro/convex/schema";

const vextro = createVextroSchema({
  collections: [pages, posts],
  globals: [mainMenu],
  blocks: {
    definitions: [hero, cta],
    commonOptions: { /* ... */ },
  },
});

export default defineSchema({ ...vextro.tables });

This is equivalent to calling createVextroBlocksSchema, createVextroCollectionsSchema, and createVextroGlobalsSchema separately and merging the results.

Block field validator

The blocksField return value is a v.array(v.union(...)) Convex validator built from all registered block types. Each union member validates a BlockEntry -- either an inline entry or a reference entry -- for a specific block type. Use it when you need to define a blocks column on a table manually rather than through f.blocks().

Reference entries follow the { kind: 'reference', blockType, blockId, order, componentSource } shape. Inline entries follow { kind: 'inline', blockType, blockId, order, data } where data contains the embedded field values. Legacy entries without a kind field are treated as reference entries for backward compatibility -- use normalizeBlockEntry() from vextro to apply this coercion at runtime.

Previous
Defining Blocks