Blocks

Nested Blocks & blockRef

Page-builder patterns often require blocks that contain other blocks -- a Container holding Rows, each Row holding Cards. The blockRef() helper breaks circular dependency chains so these recursive structures can be defined.

The problem

When two block types reference each other, a circular import occurs. Container needs Row in its children field, and Row needs Container in its children field. You cannot pass full definitions because each requires the other to exist first.

Using blockRef()

blockRef() creates a lightweight forward reference that carries only the properties f.blocks() needs. Full definitions are used later when generating schemas.

import { f, defineVextroBlock, blockRef, createVextroBlocksSchema } from "vextro";

const card = defineVextroBlock({
  slug: "card",
  label: "Card",
  tableName: "cardBlocks",
  group: "Layout",
  fields: {
    heading: f.text({ required: true }),
    body: f.richText(),
    image: f.image({ relationTo: "media" }),
  },
});

const row = defineVextroBlock({
  slug: "row",
  label: "Row",
  tableName: "rowBlocks",
  group: "Layout",
  fields: {
    columns: f.select({ options: ["2", "3", "4"], defaultValue: "2" }),
    children: f.blocks([
      blockRef({ slug: "card", label: "Card", tableName: "cardBlocks" }),
      blockRef({ slug: "container", label: "Container", tableName: "containerBlocks" }),
    ]),
  },
});

const container = defineVextroBlock({
  slug: "container",
  label: "Container",
  tableName: "containerBlocks",
  group: "Layout",
  fields: {
    maxWidth: f.select({ options: ["sm", "md", "lg", "full"], defaultValue: "lg" }),
    children: f.blocks([
      blockRef({ slug: "row", label: "Row", tableName: "rowBlocks" }),
      blockRef({ slug: "card", label: "Card", tableName: "cardBlocks" }),
    ]),
  },
});

const { tables } = createVextroBlocksSchema({
  definitions: [card, row, container],
});

This enables deeply nested page structures:

Page
  Container (full width)
    Row (3 columns)
      Card ("Feature A")
      Card ("Feature B")
      Container (nested)
        Row (2 columns)
          Card ("Detail 1")
          Card ("Detail 2")

Rules

  • Use blockRef() inside f.blocks() for forward and circular references between blocks.
  • Use full VextroBlockDefinition objects (from defineVextroBlock()) for createVextroBlocksSchema() and buildAdminDefinitions().
  • A blockRef() requires slug, label, and tableName. Optionally include picker for picker dialog configuration.
  • You can mix full definitions and blockRef() refs in the same f.blocks() call -- use full definitions for non-circular references and refs for circular ones.
  • Use isBlockRef(spec) to distinguish a blockRef() from a full VextroBlockDefinition at runtime. It returns true for refs and false for full definitions.

Editor workflow

Each block opens its own edit page in the admin UI. When a blocks field contains nested block references, each selected block item displays an "Edit" link. Clicking the link opens the nested block's edit page in a new tab, preserving unsaved work on the parent document.

Blocks in rich text

Blocks can also be embedded inline within the TipTap rich text editor. Pass block definitions or refs to f.richText():

const section = defineVextroBlock({
  slug: "section",
  label: "Section",
  tableName: "sectionBlocks",
  fields: {
    layout: f.select({ options: ["default", "wide", "narrow"] }),
    content: f.richText({
      blocks: [card, cta, blockRef({ slug: "testimonial", label: "Testimonial", tableName: "testimonialBlocks" })],
    }),
  },
});

Ref resolution

A blockRef() is resolved at schema generation time. If the referenced slug does not match any definition passed to createVextroBlocksSchema(), schema generation will throw an error listing the unresolved refs.

Previous
Schema Generation