Blocks

Blocks Overview

The block system lets editors build flexible page layouts from predefined block types. Blocks can be stored inline in the parent document (one-off blocks) or in dedicated Convex tables as shared blocks that can be reused across documents. Parent documents store an ordered array of BlockEntry values that identify each block and its storage mode. Editors add, reorder, and remove blocks from an inline picker, while Vextro handles storage, reference tracking, and schema generation automatically.

How blocks work

Every block type maps to a dedicated Convex table. When you define a hero block with tableName: "heroBlocks", Vextro creates that table with your custom fields plus automatic system fields: status, updatedAt, updatedBy, usedIn, and thumbnail. The parent document stores only lightweight references -- the actual block data lives in the block table.

Vextro tracks which documents reference each block via the usedIn array. When a document is saved, deleted, or restored, reference tracking updates automatically. If a block is used by multiple documents, editors see a confirmation dialog before saving changes.

Two hosting modes

Blocks can live in one of two places:

  • Convex component (vextroBlocks) -- the default. Block tables are isolated in a dedicated component, keeping your main Convex dashboard clean. Best for projects with many block types. See Component Architecture.
  • Main app namespace -- opt in per collection with blockBehavior: true. Block tables stay alongside your application tables, preserving full v.id() type safety. See Collection Opt-In.

Both modes can be mixed in the same f.blocks() field. Vextro routes operations to the correct location using the componentSource discriminant on each block reference.

Inline vs shared blocks

Blocks operate in two storage modes:

  • Inline (one-off) -- the block's data is embedded directly in the parent document. No separate table row is created. This is the default when adding new blocks in the editor. Inline blocks are fast to create and edit, but can only be used in one document.
  • Shared (reference) -- the block's data lives in its block table. The parent document stores a reference. Shared blocks can be reused across multiple documents and are updated in one place.

The editor allows promoting inline blocks to shared ("Save as Shared Block") and detaching shared blocks to create local copies ("Detach -- Make Local Copy").

At the storage level, each block entry is a BlockEntry discriminated union:

// Inline block -- data embedded in parent
{ kind: 'inline', blockType: 'hero', blockId: 'local_...', order: 0, data: { heading: 'Hello' } }

// Reference block -- points to shared table row
{ kind: 'reference', blockType: 'hero', blockId: 'abc123', order: 1 }

Existing documents using the old { blockType, blockId, order } format are automatically treated as reference blocks for backward compatibility.

Vextro exports helper functions for working with BlockEntry values at runtime:

FunctionDescription
isInlineBlock(entry)Type guard -- returns true when entry.kind === 'inline'
isReferenceBlock(entry)Type guard -- returns true when entry.kind === 'reference'
normalizeBlockEntry(raw)Normalizes a raw stored value -- entries without kind are treated as reference blocks
serializeBlockEntries(entries)Reindexes order, recurses into children, and returns a clean array ready to persist

Inline editing

Block fields render directly inside each block's card in the admin editor. For inline blocks, fields are editable in-place -- no need to open a separate editing panel. For shared blocks, fields are displayed read-only with a link to edit the shared source.

The "Add Block" control appears as an inline popover at insertion points between blocks. Block type options are organized into groups defined by each block's picker.group metadata (e.g., "Layout", "Content", "Marketing"). Blocks without a picker.group appear in an "Other" group. A search filter and a "Shared Blocks" tab let editors quickly find and insert references to existing shared blocks.

Quick example

Define a block type with defineVextroBlock(), then reference it in a collection field with f.blocks():

import { f, defineVextroBlock, defineVextroCollection } from "vextro";

const hero = defineVextroBlock({
  label: "Hero",
  tableName: "heroBlocks",
  fields: {
    heading: f.text({ required: true }),
    subheading: f.textarea(),
    backgroundImage: f.image({ relationTo: "media" }),
  },
});

export const pages = defineVextroCollection({
  label: "Pages",
  collectionType: "content",
  tableName: "pages",
  fields: {
    title: f.text({ required: true }),
    content: f.blocks([hero]),
  },
});

The stored data on the parent document looks like:

[
  { "kind": "inline", "blockType": "heroBlocks", "blockId": "local_abc", "order": 0, "data": { "heading": "Hello" } }
]

New blocks added through the editor start as inline entries. Promoting to a shared block converts the entry to { "kind": "reference", "blockType": "heroBlocks", "blockId": "abc123", "componentSource": "vextroBlocks", "order": 0 }.

Next steps

Previous
Schema Diagnostics