Custom Blocks
Define custom block types directly in your frontend configuration via the blocks option in initBridge. No Volto plugin deployment required. Each block type needs an id, title, and a blockSchema with its field properties.
initBridge() Reference
initBridge(options) opens the iframe bridge and registers your frontend's page and block configuration with the admin. Call it once during page setup when running inside the admin iframe.
import { initBridge } from '@hydra-js/hydra.js';
const bridge = initBridge({
page: { /* page-level blocks fields */ },
blocks: { /* block type registry */ },
voltoConfig: { /* other Volto settings */ },
onEditChange: (formData) => { /* re-render on edit */ },
pathToApiPath: (path) => path,
debug: false,
});page — page-level blocks fields
Defines the blocks fields of a page where blocks can live. page.schema.properties is keyed by field name; each widget: 'blocks_layout' entry is one blocks field. The field name is the key inside the page's blocks_layout dict (the default field is items), so they all persist inside the registered blocks_layout field.
page: {
schema: {
properties: {
items: { widget: 'blocks_layout', title: 'Content', allowedBlocks: ['slate', 'image', 'slider'] },
header: { widget: 'blocks_layout', title: 'Header', allowedBlocks: ['slate'], maxLength: 3 },
footer: { widget: 'blocks_layout', title: 'Footer', allowedBlocks: ['slate', 'link'] },
},
},
}Per-field options:
- `title` — sidebar section title (defaults to the field name).
- `allowedBlocks` — array of block-type names this region accepts. Acts as a per-region filter on top of the registry.
- `allowedTemplates` — array of template URLs shown in the BlockChooser's "Templates" group for this field. See Templates.
- `allowedLayouts` — array of template URLs shown in the Layout dropdown for this field.
- `maxLength` — maximum number of blocks in the field.
Defaults and side effects:
- If you don't include
blocks_layout, it's auto-added with{ title: 'Blocks' }. - The sidebar shows one section per field when no block is selected.
- Auto-restrict: any block type that's not in any field's
allowedBlocksis auto-restricted (hidden from the BlockChooser globally). To bypass, set the block'srestrictedto a function instead oftrue/false. - Fields not present in saved page data are auto-initialised with
{ items: [] }on load. - You can't currently change the page metadata schema itself — custom content types are created via "Site Setup > Content types" in Volto.
blocks — block type registry
Defines or overrides individual block types. Each key is the block type name (matching what appears in allowedBlocks and @type on saved blocks).
blocks: {
slider: { // new custom block
id: 'slider',
title: 'Slider',
icon: 'data:...',
group: 'common',
mostUsed: true,
blockSchema: { properties: { /* fields */ } },
},
slate: { // override the built-in slate block
blockSchema: { /* override */ },
},
}Per-block options (most are passed through to Volto's block config):
- `id` — block type identifier (matches the key).
- `title` — display name in the BlockChooser.
- `icon` — icon shown in the BlockChooser (data URL or SVG component).
- `group` — chooser group (e.g.
'common'). - `restricted` —
truehides the block from the chooser; can also be a function for conditional restrictions. - `mostUsed` — pin to the top of the chooser.
- `disableCustomSidebarEditForm` — set
trueto use only the schema form in the sidebar (no custom edit component). - `blockSchema` — JSON-schema-style definition of the block's fields. See Schema Enhancers below and the Block reference.
- `fieldMappings` — block-to-block conversion rules. See Block Conversion & fieldMappings below.
- `schemaEnhancer` — recipe-based schema modifier; supports
fieldRules,inheritSchemaFrom, etc. See Schema Enhancers.
page and blocks interact via name lookup: a region's allowedBlocks: ['slate', 'slider'] references keys of the blocks registry. You can use one without the other — page alone restricts placement of built-in blocks; blocks alone registers custom types and gets a default blocks_layout region accepting everything.
Other top-level options
- `onEditChange(formData)` — callback invoked with the new form data whenever the editor changes anything. See Live Preview › Setting Up the Bridge.
- `pathToApiPath(path)` — function transforming a frontend path to the API/admin path on
PATH_CHANGEmessages. Use when your frontend embeds state (paging, filters) in URL segments that don't exist on the CMS side. See Listings › Path Transformation. - `voltoConfig` — passes additional Volto config (non-block settings) through to the admin. Future home for things like slate formats (TODO #109) and toolbar actions.
- `debug` —
trueenables verbose console logging in the bridge. Defaultfalse.
Returns
The Bridge instance, which exposes additional API methods you can call from the frontend (e.g. getAccessToken(), sendBlockUpdate(), sendBlockAction()). See Advanced › Custom Sidebar UI for those.
Defining a custom block
const bridge = initBridge({
page: {
schema: {
properties: {
blocks_layout: {
title: 'Content',
allowedBlocks: ['slate', 'image', 'video', 'slider'],
},
},
},
},
blocks: {
slider: {
id: 'slider',
title: 'Slider',
icon: 'data:...',
group: 'common',
restricted: false,
mostUsed: true,
disableCustomSidebarEditForm: false,
blockSchema: {
properties: {
slider_timing: {
title: 'Delay',
widget: 'float',
},
slides: {
title: 'Slides',
widget: 'blocks_layout',
allowedBlocks: ['slide', 'image'],
defaultBlockType: 'slide',
}
},
}
},
slide: {
id: 'slide',
title: 'Slide',
blockSchema: {
properties: {
url: { title: 'Link', widget: 'url' },
title: { title: 'Title' },
image: { title: 'Image', widget: 'image' },
description: { title: 'Description',
widget: 'slate' },
},
},
},
},
});Child block types (like slide above) must be defined at the top level of blocks. You can also:
- Set
restricted: trueto hide a block from the block chooser (only usable as child blocks) - Set
mostUsed: trueto pin a block to the top of the chooser - Set
disableCustomSidebarEditForm: trueto use only the schema form in the sidebar (no custom edit component) - Use
fieldsetsin the schema to organize fields into tabs
A `widget: 'slate'` field holds one top-level node. A slate field — like description on the slide above — stores a single paragraph, heading, or list, not a document of several. Pasting or typing multiple paragraphs into it flattens them back into one node; only the built-in slate block splits multi-node content into separate blocks. Design slate fields for single-node content, and use a blocks_layout/object_list of slate blocks when you need several. See Visual Editing › One top-level node per slate field.
Schema Enhancers
Schema enhancers modify block schemas dynamically:
const bridge = initBridge({
blocks: {
myBlock: {
blockSchema: {
properties: {
mode: {
title: 'Mode', widget: 'select',
choices: [['simple', 'Simple'], ['advanced', 'Advanced']],
},
advancedOptions: { title: 'Advanced Options', type: 'string' },
},
},
schemaEnhancer: {
fieldRules: {
advancedOptions: { when: { mode: 'advanced' }, else: false },
},
},
},
},
});`fieldRules` — add, remove, or conditionally modify field definitions. The value for each rule key can be:
false— always hide the field{ set: { title: '...', widget: '...' } }— always add or replace the field definition{ when: { fieldName: value }, else: false }— show only when condition met{ when: { fieldName: { gte: 2 } }, set: { ... } }— conditional definition override[rule, rule, ...]— switch: first matching rule wins. A barefalsein the array is a catch-all hide:[{ when: A }, { when: B }, false]shows on A or B, hides otherwise.'parent.child': false— hide a field inside a widget's inner schema
Condition operators: is, isNot, isSet, isNotSet, oneOf, notOneOf, contains, notContains, containsAny, notContainsAny, containsAll, notContainsAll, regex, notRegex, gt, gte, lt, lte. A bare value ({ mode: 'advanced' }) is shorthand for is.
Each operator is driven by the field's declared type, never the value shape. A field reduces to one of four surfaces, and an operator used off its surface raises an error (a mis-authored rule fails loudly rather than silently mismatching):
surface | fields | operators |
|---|---|---|
string | text, textarea, url, Choice, slate (its plaintext) |
|
number | integer, float, number |
|
boolean | boolean |
|
array | multiselect (its values), region (its child block types) |
|
oneOf (scalar value ∈ set) and containsAny (array shares any with a set) differ only on the field side — oneOf is for a single-valued field, containsAny for a multiselect; oneOf on an array throws (use containsAny).
Two extras drive position- and type-aware rules:
- The virtual field `@index` reads a block's ordinal position within its parent
object_listregion (anumbersurface) —{ '@index': { lt: 1 } }means "first in my region", and../@indexis the parent block's index. Distinct from a region'scount(which counts children). - A rule whose `set` is a block-type NAME (a string) rather than a field definition is a `@type` rule — it changes the item's type by position, not a field. Declared as
typeRuleon a typedobject_list; see `typeRule` — position picks a typed item's `@type`. The retype is applied by CONVERSION (a schema enhancer can't rewrite stored@type), which brings up the confirm described under Drag / paste via conversion.
schemaEnhancer: {
fieldRules: {
// multiselect `elements: ['image','date','tag']` — reveal each option's field
date: { when: { elements: { contains: 'date' } }, else: false },
media: { when: { elements: { containsAny: ['image', 'video'] } }, else: false },
layout: { when: { elements: { containsAll: ['image', 'date'] } }, else: false },
// scalar Choice
invert: { when: { colour: { oneOf: ['brand-dark', 'black'] } }, else: false },
// text: substring / pattern
cta: { when: { title: { contains: 'Sale' } }, else: false },
year: { when: { title: { regex: { pattern: '\\b20\\d\\d\\b', flags: 'i' } } }, else: false },
},
}For a region (an object_list field, or a single blocks_layout region named by its region key), the array surface is its child block types, and the numeric operators count that region's children — only its own, never a cross-region total:
schemaEnhancer: {
fieldRules: {
// reveal a caption field only when the `body` region has an image block
caption: { when: { body: { contains: 'image' } }, else: false },
// offer "columns layout" only once the `columns` region has ≥2 blocks
columnsLayout: { when: { columns: { gte: 2 } }, else: false },
// "carousel options" only when the `slides` object_list has >1 item
carouselOptions: { when: { slides: { gt: 1 } }, else: false },
},
}To condition on a block's position rather than a field value, use the virtual field `@index` — a block's ordinal index within its parent object_list region (a number surface). It composes with the block-step grammar, so ../@index is the parent block's index. Unlike the region's numeric ops (which count children), @index is where this block sits:
schemaEnhancer: {
fieldRules: {
// a table cell's blocks region: cap at one block when this cell is in the
// first row (a header row) — `../@index` is the cell's ROW index
blocks: [
{ when: { '../../headerMode': { oneOf: ['row', 'both'] }, '../@index': { lt: 1 } }, set: { maxLength: 1 } },
{ when: { '../../headerMode': { oneOf: ['col', 'both'] }, '@index': { lt: 1 } }, set: { maxLength: 1 } },
],
},
}A block that isn't an object_list item yields an unset @index, so comparisons are simply false (never an error). lt: 1 is "first"; lt: 2 is "first two", etc.
Field paths: ../field for the parent block's field (and @index / ../@index for position), /field for a page metadata field.
Block Conversion & fieldMappings
fieldMappings (plural) on a block config defines how fields map between block types (and from linked content). This enables:
- "Convert to..." UI action — editors can convert a block to another type (e.g. teaser → image).
- Listing item types — query results are mapped to item blocks via
@default(see Listings). - Synchronised container children — a parent controls child type, all children convert together (see Container Blocks › Synchronised Block Types).
- Drag / paste via conversion — a block can be dropped or pasted into a container that only accepts a convertible type; it's converted on drop (see below).
- Copy from a linked target — a block pulls fields from the content item its link field points at, with a per-field linked/custom toggle (see `@target`).
Each key in fieldMappings is either a specific block type name, `@default`, or `@target`.
@default — the canonical content shape
@default is a virtual type representing a linked content item's fields — anything a catalog search returns as metadata (metadata_fields: '_all'): @id, title, description, image, Subject (tags), created/effective dates, and so on. A block with fieldMappings['@default'] is saying "I can be populated from a content item." The keys are content/metadata field names — not this block's own field names (e.g. label, field, required are not content metadata and are invalid).
Explicit type-to-type mappings
Use these when blocks share fields that aren't part of the @default set — for example, facet types sharing { title, field, hidden } or form field types sharing { label, description, required }.
// Content item types: use @default (canonical fields) + explicit cross-mappings
teaser: {
fieldMappings: {
'@default': { '@id': 'href', 'title': 'title', 'image': 'preview_image' },
image: { 'href': 'href', 'alt': 'title', 'url': 'preview_image' },
},
},
image: {
fieldMappings: {
'@default': { '@id': 'href', 'title': 'alt', 'image': 'url' },
teaser: { 'href': 'href', 'title': 'alt', 'preview_image': 'url' },
},
},
// Non-content types: use explicit hub-type mappings (NOT @default).
// All facet types map through checkboxFacet as a hub:
selectFacet: { fieldMappings: { checkboxFacet: { title: 'title', field: 'field', hidden: 'hidden' } } },
checkboxFacet: { fieldMappings: { selectFacet: { /* ... */ }, daterangeFacet: { /* ... */ } } },@target — copy from a linked content item
@target maps a linked content item's attributes onto this block's own fields — the generic version of the Volto teaser's "copy from target" button. It maps source content attributes (title, description, image, …) to this block's fields. The item is whichever the block's link field points at (the object_browser mode: 'link' field — its stored snapshot is the source), so you don't name a URL field separately: "the url is the link in the mapping".
button: {
// The Label (title) syncs from the linked item's title.
fieldMappings: {
'@target': {
title: 'title',
description: 'description',
// image: the conversion is derived from the destination field's
// widget, so the value is assembled into the shape it expects.
image: 'preview_image',
},
},
},Declaring @target is the only opt-in — no per-block enhancer wiring. Each mapped field then shows a small 🔗 pull from linked toggle in the sidebar (only when a target is selected). Every mapped field is one of two states:
- Linked (default, toggle ticked) — the field pulls from the linked item.
Its value is filled from the target's snapshot when the page opens for editing and re-pulled when you change the link, so it always mirrors the linked content.
- Custom (toggle unticked) — your own value, ignored by the target. A field
becomes custom the moment you edit it, or when you untick the toggle; re-ticking re-pulls the target value. Custom fields are recorded in the block's _customFields array (absence ⇒ linked), so the state persists with the block.
Container ⇄ value (region-crossing paths)
A fieldMappings value is usually a sibling field name. It may instead be a region-crossing path <region>/<type|*>/<field>, which reaches the <field> of a container region's children — the one place the path grammar crosses a region boundary. This bridges a container block (a region of child blocks) and a value block (a scalar field), so a block can convert between the two shapes:
tableHeaderCell: { // the value form: one slate
blockSchema: { properties: { value: { widget: 'slate' } } },
// Declared ONCE on the value block; works both directions.
fieldMappings: { tableCell: { value: 'blocks/slate/value' } },
},
tableCell: { // the container form
blockSchema: { properties: {
blocks: { widget: 'object_list', typeField: '@type',
allowedBlocks: ['slate', 'image', 'video'] },
} },
},- container → value (collapse) — gather the region's matching children's
<field>; slate values are merged into one (lossless), not truncated.
- value → container (expand) — wrap the value in one child of
<type>in
the region.
<type>selects a child type;*= any child that exposes<field>(siblings
without it — an image for a value path — are skipped). A concrete type (blocks/slate/value) makes expand unambiguous, so use it for a two-way bridge; * suits read-only cross-region reads (e.g. a when condition).
Non-region scalar fields (key, width, …) carry over unchanged. This is the convertValueContainer helper; DnD/paste and the block chooser reuse it via the same fieldMappings graph. See proposals/container-value-conversion.md.
typeRule — position picks a typed item's @type
The bridge converts on demand; a `@type` rule on a typed object_list field decides when, by position. It is an ordinary when-based fieldRule (same grammar — @index, ../@index, ../../<field>, oneOf, lt, …) whose set is a block-type name instead of a field definition:
cells: {
widget: 'object_list', typeField: '@type',
allowedBlocks: ['tableCell', 'tableHeaderCell'],
typeRule: [
// header row OR header column → the value form
{ when: { '../../headerMode': { oneOf: ['row', 'both'] }, '../@index': { lt: 1 } }, set: 'tableHeaderCell' },
{ when: { '../../headerMode': { oneOf: ['col', 'both'] }, '@index': { lt: 1 } }, set: 'tableHeaderCell' },
{ set: 'tableCell' }, // otherwise the container form
],
},The rule is evaluated in the same pass that applies field defaults (run on every edit): each typed item's target @type is re-resolved, and when it differs from the stored @type the item is converted in place via the bridge above. So moving a row to/from row 0 flips its cells between tableHeaderCell (a slate value) and tableCell (a blocks container), losslessly — no imperative "re-type the cells" code. Only meaningful on a typed object_list (a typeField item has an @type to rewrite); it settles in one pass (the target type re-resolves to itself once the item is in place).
Each field's value is converted to the shape its destination widget expects (derived from the widget): strings copy across, an image field is assembled from the target's image_scales / image_field, multi-value fields (e.g. Subject → tags) pass through as-is.
The pull is snapshot-based — there is no separate live fetch. When you pick or type a link, the url widget stores the target's full metadata onto the link field (the object browser already fetches every item with metadata_fields: '_all', and the field's selectedItemAttrs keeps the whole canonical set), so the block carries its own source data. Every mapped field then pulls straight from that stored snapshot: on page open (all blocks fill at once) and again whenever you change the link.
Only an internal link is a pull source. An external URL has no catalog item to search, so a field linked to one can't pull — the toggle is hidden and the field behaves as a plain editable field. (Unfurling external links via OpenGraph is a future enhancement.)
Conversion graph rules
- Explicit
fieldMappings[typeName]always creates a conversion edge. @defaultonly creates edges between types that both have valid@defaultmappings (keys from{ @id, title, description, image }). Types with non-canonical@defaultkeys are ignored.- Types without
fieldMappingsnever appear in the "Convert to..." menu. - Transitive conversions use paths through intermediate types (e.g. hero → teaser → image).
- Unmapped fields are kept in the data so converting back restores them.
Drag / paste via conversion
The same conversion graph gives drag-and-drop (and paste) more valid destinations: a block can be dropped or pasted into a container whose allowedBlocks only admits a type the block can convert to.
Every drop/paste is TRIALLED before it commits. The candidate result is normalised (the same pass that applies field defaults and evaluates `@type` rules), then each block's @type is diffed against what was dropped. If anything converted — because the dropped block had to convert to fit the container, or because a rule re-typed a block by its new position (e.g. a table row moved to row 0 turns its cells into header cells) — a "Convert blocks?" confirm lists each from → to and waits: Convert commits the already-converted result, Cancel aborts the whole drop. Nothing converted → it commits silently.
The chooser popup survives only for the genuinely ambiguous case: a single block reachable to several target types, where you pick which one (cancelling leaves it untouched). Zero reachable types rejects the drop; multi-block selections are auto-only (every member must reach exactly one type). On mobile, conversion happens via cut → paste (drag/chevron move stays native-only). External-link and other type restrictions are unaffected; only the container's allowedBlocks gate is relaxed to "allowed or convertible".
Mapping value format
A mapping value is either a string (simple field rename) or { field, type } (rename with type conversion):
{
"@id": { "field": "href", "type": "link" },
"title": "title",
"description": "description",
"image": "preview_image"
}When type is specified, the value is converted at runtime:
Type | Conversion |
|---|---|
| Arrays joined with |
| String wrapped as |
| Pass through (expects |
| Non-arrays wrapped in |
| Copied as-is |
FieldMappingWidget
When a parent block has mappingField set in its inheritSchemaFrom recipe, the admin sidebar shows a widget that lets editors configure field mappings visually:
- Shows the
@defaultsource fields (@id,title,description,image) on the left. - For each source field, lets the editor pick a field from the selected child type's schema.
- Auto-detects the conversion
typefrom the target field definition (e.g.object_browserwithmode=link→type: "link"). - Saves the result as
fieldMapping(singular) on the block data.
The saved fieldMapping is read at render time by expandListingBlocks — no block registry access needed at render time.
HTML Paste Support (TODO)
When the editor pastes rich HTML into the page, Inka will eventually be able to recognise it as a custom block by matching against a CSS selector mapping. The proposed shape:
video: {
fieldMappings: {
'css:video': { 'src': 'url', 'caption[@class="alt"]': 'alt' },
},
}The css:<selector> key in fieldMappings matches a pasted HTML element; the value maps element attributes to block fields. Not yet implemented — open question on whether this should run via htmlTagsToSlate (bypassing slate conversion) or be encoded into slate so attributes/classes survive.