Skip to main content

Overview

The block system is split across two packages: The split exists because the orchestrator (Node/Fastify) needs block schemas for validation and AI planning but has no React dependency. Renderers live in a separate package so React is only pulled in where it’s needed.

How a Block Is Defined

Every block has three parts, co-located in a single file in packages/shared/src/blocks/:

Schema

The Zod object defines the data shape — field types, enums with defaults, required vs optional, array constraints. This is the source of truth for validation. The orchestrator runs validateBlockProps() against this schema before applying any operation.

Metadata (meta)

Rich metadata layered on top of the schema:
  • fields — per-field FieldMeta with kind, label, imageSpec, inlineEditable
  • listFields — describes array fields (like cards in CardGrid) with item-level field metadata
  • displayName, description, category — used by the editor’s block picker and property panel
  • chrome — if true, the block is structurally pinned (e.g. SiteHeader, Footer) and cannot be added, moved, or removed

Default Props

An exported function (e.g. bannerDefaultProps()) that returns sensible starter content. Used when the AI or user adds a new block — the defaults are the starting point that the AI then modifies.

Field Metadata Vocabulary

The _helpers.ts file provides factory functions for declaring field metadata:
The kind field drives behavior across the stack: That is the complete vocabulary — thirteen kinds. It is written in two places that a compiler check keeps in step: the FieldKind union and the FIELD_KINDS list behind fieldMetaSchema. select and textarea are not kinds, though they are the natural guesses; use enum and text with multiline instead.

Three flags that change what a field means

kind says how a field is drawn. These say whether it should be drawn, or mentioned, at all — and each one exists because its absence produced a specific class of bug.
A declaration cannot clear internal on an underscore-prefixed key. The value in _key is the address a field-level publish patches an array member by — editing it re-points or orphans the very patch that would have saved it.

Why reference refuses the edit

A CMS reference is a pointer, not a URL. Storyblok stores an internal link as { linktype: "story", id: <uuid>, cached_url: "faq" } and renders it per language — /faq on the German page, /fr/faq on the French one. Neither rendered string is what the document holds; Contentful entry links and Sanity references have the same shape. Flattening that to an href breaks the projection in both directions at once. The publish diff reports the reference as changed on every page forever, because the rendered href never equals the stored object. And writing the href back replaces the pointer with a hardcoded URL — the page renders identically and the link silently stops following renames, which is the one thing the reference was for. So the panel shows where it points and offers no control, the planner is not told the prop exists, and an update_props naming one is dropped with a note saying the change belongs in the CMS. referenceLabel reads the three CMS spellings so “opaque” does not mean [object Object] in the one place a person checks where a CTA goes; referenceLabelKey overrides it for a readable key under an unguessable name.
Re-pointing a reference needs the CMS’s own document ids, which only an integration has. Showing the target and refusing the edit is the honest answer until a picker exists; a text input is the corrupting one.

How a Block Is Rendered

Renderers live in packages/blocks/src/blocks/{type}/renderer.tsx:
Key patterns:
  • Untyped props — renderers accept Record<string, unknown> and coerce to safe types. Validation happens upstream in the orchestrator.
  • data-editable-target — marks DOM elements for inline editing in the preview overlay. The value matches a prop key.
  • No imports from shared (usually) — renderers are stateless view functions. They don’t validate or re-fetch metadata.

How They Connect

Schemas and renderers are joined by type name convention — the string "Banner" passed to registerBlock() must match the key in the renderers map: SharedBlockRenderer is the glue:
It checks the built-in renderer map first, then falls back to custom renderers registered at runtime (for site-specific blocks from migrations or CMS integrations).
There is no compile-time check that every schema has a matching renderer. If you add a schema in shared but forget the renderer in blocks, the block will validate but render as empty. The block catalogue page (/catalogue) is the easiest way to verify all blocks render correctly.

The Registry Singleton

The registry uses globalThis to ensure a single instance survives Next.js webpack module duplication across RSC, SSR, and API route layers:
Without this, registerBlock() in a custom block file would populate a different registry copy than getBlockMeta() reads — blocks would appear registered but metadata would be missing.

Runtime Queries

The registry exposes query functions used across the stack:

Block Manifest API

When the editor connects to a site, it fetches the block manifest from GET /api/editor/blocks. This endpoint serializes registered blocks into a JSON payload the editor and AI planner can consume:
For built-in blocks, getBlockJsonSchema() converts the Zod schema to JSON Schema and strips validation-only constraints (minLength, required, $schema, additionalProperties) — the editor only needs the structural shape. For custom blocks (external sites), the manifest is authored directly as JSON Schema in propsSchema.

Full Lifecycle


How-To Guides

Add a new block type

1

Define the schema

Create packages/shared/src/blocks/my-block.ts:
2

Register the import

In packages/shared/src/blocks/index.ts, import the module and add your defaults function to the defaults map:
The import alone registers the schema but leaves the defaults unwired, and defaultPropsForType() then falls back to a CTA-shaped object — so a new block would start life with title / description / ctaText / ctaHref.
3

Create the renderer

Create packages/blocks/src/blocks/my-block/renderer.tsx:
4

Register the renderer

Add MyBlock to the renderers map in packages/blocks/src/blocks/index.ts and to the RendererBlockType union in block-types.ts.
5

Add styles

Create packages/blocks/src/blocks/my-block/styles.css and import it from packages/blocks/src/blocks/styles.css.
6

Verify

  • pnpm typecheck — catches missing fields or type mismatches
  • Visit /catalogue on the site to see the block render with default props
  • Open the editor and ask the AI to “add a MyBlock” — the planner should pick it up from the manifest

Add a field to an existing block

1

Update the Zod schema

Add the field to the block’s z.object() in packages/shared/src/blocks/{type}.ts. Use .optional() if it’s not required.
2

Add field metadata

Add an entry to meta.fields using the f.* helpers. Choose the right kind — it determines editor UI, AI behavior, and inline editability.
3

Update default props

If the field should have a starter value, add it to the *DefaultProps() function.
4

Update the renderer

Read the new prop in the renderer component. Add data-editable-target="fieldName" if it should be inline-editable in the preview.
5

Verify

pnpm typecheck then check the block catalogue and editor.

Add a list field (repeatable items)

1

Define the array in the schema

2

Add listFields metadata

3

Update the renderer

Cast and iterate:
Note the features[0].title path format in data-editable-target — this enables inline editing of list items.

Add AI guidance for a block

If the AI makes mistakes with your block’s props (wrong enum values, missing cross-field dependencies), add a note in packages/orchestrator-core/src/nlp/deterministic-planner-suggestions.ts:
Notes are injected into the AI contract as natural language guidance. They’re most valuable for:
  • Enum semantics (“use center textAlign with full imagePosition”)
  • Cross-field dependencies (“full-bleed variant REQUIRES imageUrl”)
  • Richtext conventions (which markdown subset is supported)
  • Optional field toggle behavior (“omit or set empty to hide”)
See Block Schema Contracts for details on how contracts are assembled and sent to the LLM.

Add image fields with AI resolution

Mark image fields with f.image() and include an imageSpec:
The imageSpec tells the AI planner what dimensions to request from the AI image generator (Gemini or OpenAI gpt-image-*, per IMAGE_GEN_PROVIDER) or Unsplash. The imageAlt kind pairs with the image field for accessibility. Both are auto-detected by getImageFields() and getImageSpec() — no additional wiring needed. For list items with images, declare them in listFields.itemFields: