Page builder (block types and blocks)
Pages and collection entries can store their content as a tree of blocks. A block is an instance of a block type – a snippet with a schema (typed parameters, slots for child blocks). Only data is stored, never HTML; rendering uses the component mechanism ({% component %}/{% slot %}) via the tag {% blocks %}. A block tree lives in three places:
- on pages in the field
blocks, - in collection fields of type
blocks, - in global blocks – reusable trees that other trees reference via
{"global": id}.
Block type schema
The field schema on a snippet turns it into a block type (null = no block type; PUT without schema leaves it unchanged, "schema": null removes it):
{
"block": true,
"label": "Hero",
"description": "Großes Titelbild mit Überschrift",
"icon": "image",
"category": "Inhalt",
"params": [
{ "name": "titel", "label": "Titel", "type": "text", "required": true },
{ "name": "bild", "label": "Bild", "type": "image" },
{ "name": "text", "label": "Text", "type": "markdown" },
{ "name": "variante", "label": "Variante", "type": "select", "options": ["hell", "dunkel"], "default": "hell", "design": true },
{ "name": "produkt", "label": "Produkt", "type": "reference", "collection": 3 }
],
"slots": [
{ "name": "default", "label": "Inhalt", "allow": ["text", "bild"] },
{ "name": "buttons", "label": "Buttons", "allow": ["button"], "max": 3 }
]
}
| Field | Description |
|---|---|
block |
true = offer as a block type in the form editor. A schema with false stays stored, but the block type cannot be chosen and blocks of this type are rejected on save |
label, description, icon, category |
Display in the editor (empty label = snippet name) |
params[] |
Parameters like collection fields: name ([a-zA-Z_][a-zA-Z0-9_]*, unique, not block, slots, id), label, type (all field types except blocks), required, options (for select), collection (for reference/references, must exist) |
params[].default |
Default value (must match the type): used when rendering if the block does not set the parameter, and satisfies a required parameter on save |
params[].design |
Design parameter (variant, spacing, colors) – in the CMS display only (separate group “Appearance”); IllusionPublish uses it for field permissions |
slots[] |
name (default = default slot {% slot %}), label, allow (allowed block types as snippet names; missing/empty = all), max (at most this many blocks, 0 = unlimited) |
In the snippet, the parameters are available as variables ({{ titel }}), the slots via {% slot "buttons" %}{% endslot %} (default slot {% slot %}), slots.<name> is true if a slot has content, and block is { "id": …, "type": … }.
Errors on save: 400 with the codes block.paramNameInvalid, block.paramTypeInvalid, block.paramLinkMissing, block.paramDefaultInvalid, block.slotNameInvalid (see error codes). Warnings do not reject; they are listed in the response of POST/PUT /snippets under warnings (same format as errors):
{ "id": 7, "name": "hero", "schema": { "block": true, "…": "…" },
"warnings": [{ "code": "block.slotNotInContent", "error": "slot \"buttons\" is not used in the snippet ({% slot \"buttons\" %})", "params": { "slot": "buttons" } }] }
block.slotNotInContent – a slot of the schema does not appear in the snippet as {% slot %}; block.slotNotInSchema – the snippet uses a slot that the schema does not define (only with block: true).
GET /blocks/types 🔑
All block types (snippets with schema.block == true), sorted by name – for the form editor and integrations:
[{ "name": "hero", "label": "Hero", "description": "Großes Titelbild mit Überschrift", "icon": "image", "category": "Inhalt",
"params": [{ "name": "titel", "label": "Titel", "type": "text", "required": true }], "slots": [{ "name": "buttons", "label": "Buttons", "allow": ["button"], "max": 3 }] }]
Empty label = snippet name, empty description = description of the snippet; params/slots are always lists.
Block tree
[
{ "id": "b7f3k2", "type": "hero", "params": { "titel": "Willkommen", "bild": 12, "variante": "dunkel" },
"slots": { "buttons": [{ "id": "b9x1aa", "type": "button", "params": { "text": "Mehr", "link": "/ueber-uns" } }] } },
{ "id": "b2m8qq", "global": 4 }
]
| Field | Description |
|---|---|
id |
Unique within the tree ([A-Za-z0-9_-]{1,64}), assigned by the client and stable across saves. The server adds missing ones and replaces invalid or duplicate ones (b + 6 characters) |
type |
Name of a block type (snippet name) |
params |
Values according to the schema: image/file = file ID, reference = entry ID, references = list of IDs, date = YYYY-MM-DD, boolean, number, select = one of the options. Converted on save like a collection entry ("3" → 3); unknown parameters are dropped. Always an object in the response |
slots |
Slot name → list of blocks (only slots with content) |
global |
Reference to a global block – then without type/params/slots |
Validation on save (pages, entries, global blocks): the block type exists and is one; required parameters are set (or have a default); values match the type; slots are in the schema; allow/max are respected; at most 10 levels; at most 1 MB (JSON); referenced global blocks exist. A reference {"global": id} in a slot with an allow list is only allowed if all top-level blocks of the global block are allowed block types (global references on its top level are resolved), otherwise block.globalNotAllowed; for max, a reference counts as one block. Errors: 400 with code and the path in the tree, e.g. { "code": "block.slotNotAllowed", "params": { "path": "0.slots.buttons.1", "slot": "buttons", "type": "text" } }. For collection fields, params.field is added.
Only what is sent is validated: PUT /pages/{id} without blocks, or a partial update of an entry without the blocks field, leaves the stored tree unvalidated – even if a block type has since been deleted or renamed. Such a block is then not rendered at all (live) or rendered as a notice (preview).
Output in templates
{% blocks expression %} renders a block tree: {% blocks page.blocks %} (page) or {% blocks post.inhalt %} (field of an entry from data()/item()). Each block’s type is rendered like a component – with the global variables (page, site, route, data() …), the parameters and the rendered child blocks as slots. Parameter values are prepared according to their type:
| Type | In the block type |
|---|---|
text, textarea, url, select, date, number, boolean |
Raw value (escaped as usual) |
markdown |
Rendered, safe HTML (like |markdown) |
html |
Safe HTML |
image, file |
File object like file(id) (id, url, srcset, webp_srcset, width, height, alt, name, private …), e.g. {{ image(bild.id) }} or {{ bild.url }}. Private files are signed in the preview only |
reference, references |
Resolved entry or list (one level, like collection references); for visitors only published ones |
Empty parameters get the schema’s default. An empty or invalid value after {% blocks %} outputs nothing. Unknown block types (deleted, no longer a block type) and missing global blocks produce no output live (with a log entry) and a visible notice box (<div class="ic-block-problem">) in the preview. CSS/JS attached to a block type are loaded, as for components, on pages that use it.
Preview markers
Only in previews (admin preview POST /pages/{id}/preview, ?preview=1 with an admin session, pen editor), HTML comments surround each block – for integrations such as IllusionPublish that make blocks clickable in the preview. There are deliberately no wrapper elements (they would break CSS like .grid > .card), and live pages, preview links and the static export never contain markers.
<!--ic-block:page.blocks/0:b7f3k2:hero--><section class="hero">…
<!--ic-block:page.blocks/0/slots/buttons/0:b9x1aa:button--><a …>Mehr</a><!--/ic-block:b9x1aa-->
</section><!--/ic-block:b7f3k2-->
<!--ic-block:page.blocks/1:b2m8qq:global:4--><!--ic-block:global:4/0:n1:text-->…<!--/ic-block:n1--><!--/ic-block:b2m8qq-->
Format: <!--ic-block:<path>:<id>:<type>--> … <!--/ic-block:<id>-->, for references to global blocks <!--ic-block:<path>:<id>:global:<gid>-->. The path starts with the origin of the tree – page.blocks, item:<collection-slug>:<entry-id>:<field> (blocks field of an entry, also for the unsaved item of the preview) or global:<gid> (children of a global block) – followed by /<index> and /slots/<name>/<index> per level. Paths contain colons; id and type are the last two parts (for global blocks the last three). If a tree without a recognizable origin is output (e.g. a composed value), the path starts with blocks.
Lists, slots and field markers
Also only in previews, three more hints are added – for visual editors such as IllusionPublish that edit content directly on the page:
<!--ic-blocks:page.blocks-->…<!--/ic-blocks:page.blocks-->
<!--ic-slot:page.blocks/0/slots/buttons-->…<!--/ic-slot:page.blocks/0/slots/buttons-->
<h1 data-ic-field="page.blocks/0/titel">Fresh from the oven</h1>
- Lists: Every output of
{% blocks … %}is enclosed in<!--ic-blocks:<origin>-->and<!--/ic-blocks:<origin>-->– an empty list too, so an editor can show an insertion point there. Origin as in the path (page.blocks,item:<collection-slug>:<item-id>:<field>, unknownblocks). - Slots: Every
{% slot %}of a block is enclosed in<!--ic-slot:<block-path>/slots/<name>-->and<!--/ic-slot:…-->, also when empty (or showing its fallback content). If the template puts the slot inside{% if slots.x %}, an empty slot has no marker. - Fields:
data-ic-field="<block-path>/<parameter>"on the element that displays a parameter. Automatic for parameters of typetext,textareaandmarkdownwhen their value forms the entire content of an element unchanged (<h1>{{ titel }}</h1>,<div class="text">{{ inhalt }}</div>, line breaks via|linebreaksbrare fine), and for image parameters on the<img>ofimage(bild.id)or on the element whose attributes contain{{ bild.url }}(src,style…). Values in attributes, scripts, in the middle of other text or changed by filters (|upper,|truncatechars) stay unmarked – it never guesses, and attributes stay unchanged.{{ block_field("titel") }}marks an element explicitly (outputsdata-ic-field="…"in the preview, nothing live):<p class="lead"{{ block_field("titel") }}>{{ titel|upper }}</p>.
How it works: the automatic marking wraps the values with invisible characters from a private Unicode area (U+F0000 ff.) while rendering and removes them from the whole document afterwards (also URL-encoded and as JS escapes). Filters that count characters (|length, |truncatechars) count these characters in the preview – the result may be slightly shorter there than live.
Global blocks
Reusable block trees (e.g. newsletter box, contact bar). A reference {"global": id} inserts the current tree of the global block when rendering – changes take effect everywhere. “Embed” (local copy) is done by the client: replace the reference with a copy of the blocks with new IDs.
{ "id": 4, "name": "Newsletter-Kasten", "folder": "Kästen", "description": "Anmeldung",
"blocks": [{ "id": "n1", "type": "text", "params": { "inhalt": "Jetzt anmelden" } }],
"created_at": 1791191665, "updated_at": 1791191700 }
| Endpoint | Access | Description |
|---|---|---|
GET /global-blocks |
🔑 | All global blocks (with blocks), sorted by folder and name |
POST /global-blocks |
✏️ | Create; required: name. 201 |
GET /global-blocks/{id} |
🔑 | A single global block |
PUT /global-blocks/{id} |
✏️ | Merge update with conflict check (base_updated_at); without blocks, the tree stays (unvalidated). Version history (kind=globalblock) |
DELETE /global-blocks/{id} |
✏️ | Only when unused, otherwise 409 block.globalInUse with params.count. 204 |
GET /global-blocks/{id}/usage |
🔑 | Usages (direct references) |
Usages: [{ "kind": "page", "id": 5, "title": "Aktuelles", "path": "/aktuelles" }, { "kind": "item", "id": 12, "title": "Sauerteig", "path": "/blog/sauerteig", "collection": "blog" }, { "kind": "global", "id": 2, "title": "Fußbereich", "path": "" }] – searched in the block trees of all pages, all entries with blocks fields and the other global blocks (no index, on every call). title for entries: field title/titel/name or the first text field; path only if the collection has a detail page.
Overrides per usage: A reference may additionally carry "overrides": { "<block ID inside the global block>": { "<parameter>": <value> } } – individual parameters of the blocks in the global tree are replaced for this usage only (parameters, never structure or slots). Values are validated like block parameters (block.paramInvalid, params.path = <path>.overrides.<id>); null clears the parameter (the block type’s default applies). Blocks and parameters that do not (or no longer) exist in the global block are dropped on save and ignored when rendering. File IDs in overrides count as used. Changes to the global block still apply everywhere, unless overridden.
Cycles: A global block may not contain itself, neither directly nor through other global blocks – on save 400 block.globalCycle; when rendering, a cycle (e.g. after a restore) stops with a notice.
Restricted slots: If a PUT with new blocks would make a global block disallowed where it already sits in a slot with an allow list (also via other global blocks), the server rejects it: 400 block.globalNowNotAllowed with params.count (affected places).
Global blocks are part of the bundle (table global_blocks, IDs are kept). File IDs in image/file parameters of all block trees count as used during cleanup.
Page builder error codes
| Code | Parameters | Meaning |
|---|---|---|
block.treeInvalid |
path |
Not a block tree (not a list, block not an object, wrong params/slots, global not an ID) |
block.fieldInvalid |
field |
Collection field of type blocks is not a list (nor JSON text) |
block.typeMissing |
path |
Block without type |
block.typeUnknown |
path, type |
The snippet does not exist |
block.notABlock |
path, type |
The snippet is not a block type (schema.block missing) |
block.paramRequired |
path, param |
Required parameter missing (without default) |
block.paramInvalid |
path, param, error |
Value does not match the type |
block.slotUnknown |
path, slot, type |
Slot is not in the schema |
block.slotNotAllowed |
path, slot, type |
Block type not allowed in the slot (allow) |
block.slotMax |
path, slot, max |
Too many blocks in the slot |
block.tooDeep |
path, max |
More than 10 levels |
block.tooLarge |
max |
Tree larger than 1 MB |
block.globalUnknown |
path, id |
Global block does not exist |
block.globalCycle |
id |
Global block would contain itself |
block.globalNotAllowed |
path, slot, id, type |
Reference to a global block in a slot with an allow list whose top level contains a disallowed block type type |
block.globalNowNotAllowed |
count |
Changing a global block would make it disallowed at count usages in restricted slots |
block.globalInUse |
count |
Deleting a global block that is in use |
block.paramNameInvalid, block.paramTypeInvalid, block.paramLinkMissing, block.paramDefaultInvalid, block.slotNameInvalid |
name (type) |
Error in the block type schema |
block.slotNotInContent, block.slotNotInSchema |
slot |
Only warnings (warnings) when saving a snippet |
path is the path in the tree with dots (0.slots.buttons.1, empty = the whole tree); for collection fields, field is added.