Widgets
Blocks field
An ordered list of widget instances stored in one JSONB column, validated against the registry on every write.
field.blocks() stores an ordered list of widgets on a Content Engine record, in one JSONB column, and validates it on every write. Use it when the widgets belong to a record, such as a landing page or an article. For widgets that belong to a page of your app rather than to a record, use an editable page instead.
Adding a zone to a content type
import { defineContentType, field } from '@vitnode/core/content'
import { PAGE_BLOCKS_ALLOWED } from './page-blocks'
export const pageContentType = defineContentType({
id: 'newsroom.page',
tableName: 'newsroom_pages',
fields: {
title: field.text({ required: true, minLength: 3, maxLength: 200 }),
slug: field.slug({ source: 'title' }),
content: field.blocks({
allowed: PAGE_BLOCKS_ALLOWED,
max: 50,
}),
},
publication: true,
// ā¦
})PAGE_BLOCKS_ALLOWED is ['core:*', 'newsroom:callout', 'newsroom:features'], kept in content/page-blocks.ts so the page that renders the field can share it. field.widgets() is an alias of field.blocks().
| Option | Default | What it does |
|---|---|---|
allowed | "*" | Which widgets may be placed here. |
max | 200 | Most widgets one record may hold. |
min | none | Fewest widgets. |
localized | false | Give every language its own widgets and order. |
min and max count widget instances, wherever they sit. A widget inside a
layout area counts towards the limit; the area
holding it does not, so an empty area counts as nothing.
A blocks() field is never required and never nullable. The empty list means "no widgets", so a create that says nothing about the zone gets []. That empty list is still checked against min, so a zone that declares min: 1 refuses a create that leaves it out.
Restricting what may go in
allowed takes three shapes, and they mix freely:
field.blocks({ allowed: '*' }) // every registered widget
field.blocks({ allowed: ['core:*', 'blog:*'] }) // two whole plugins
field.blocks({ allowed: ['core:hero', 'core:text'] }) // exactly these twoA namespace wildcard is the useful middle. Installing another plugin does not silently widen what a page may contain, while adding a widget to a plugin you already trust does. The same list format works on zones and editable pages; see Restrict widgets.
The stored shape
One JSONB column, NOT NULL DEFAULT '[]':
"content" jsonb DEFAULT '[]' NOT NULLholding an ordered list of instances:
[
{
"id": "01JC4Z8N9WQKX7R2M5T6V3B8HD",
"type": "core:hero",
"data": { "title": "Build your community", "align": "start" }
},
{
"id": "01JC4Z8N9X3R7T2M5Q6K8V4B9F",
"type": "newsroom:callout",
"data": { "title": "Heads up", "body": "...", "tone": "info" }
}
]| Key | Rule |
|---|---|
id | Stable and unique within the zone. Never the array index, since a reorder must not change what a widget is. |
type | The globally unique widget id, such as core:hero. |
data | Validated against that widget's own fields. |
variant | Optional. The look this instance picked, from the ones its widget declares. Absent means "the widget's default". |
Array order is the rendered order. Nothing sorts it.
Areas in the same list
A zone holds content nodes, and a node is either a widget instance or an Area, a small layout container with its own id, a layout and children that are widgets:
{
"id": "01JC4Z8N9X3R7T2M5Q6K8V4B9F",
"kind": "area",
"layout": { "columns": 2, "gap": 16 },
"children": [
{
"id": "01JC4Z8N9X3R7T2M5Q6K8V4B9G",
"type": "core:text",
"data": { "heading": "Left", "body": "Two widgets, side by side." }
}
]
}An Area is told apart by kind: "area" and cannot contain another Area. It needs no entry in allowed, because the API checks its children against the same list as the zone's own widgets.
Instance ids
createBlockInstanceId() produces a 26-character identifier that sorts by creation time.
import { createBlockInstance } from '@vitnode/core/widgets'
const hero = createBlockInstance('core:hero', { title: 'Build your community' })
// { id: "01JC4Z8N9Wā¦", type: "core:hero", data: { title: "ā¦" } }Storage accepts any stable id of 1-64 letters, digits, - and _, so an imported document can keep the ids it arrived with.
What the API checks on a write
Every create and update runs the zone through the registry, once, before anything is stored. Nothing re-checks it on read; the renderer only compares the stored shape with the widget's current fields.
If any instance fails, the API refuses the whole request with 400 and stores nothing:
| Refused | Message |
|---|---|
| A widget the field does not allow | Block "core:cta" is not allowed in this field. |
| A widget no plugin registers | Block "shop:cart" is not registered. Install or enable the plugin that provides it. |
| Data the widget's fields reject | Block "core:hero" data is invalid - title: Too big⦠|
| Two instances sharing an id | Block instance id "01ā¦" appears more than once. |
Each message names the instance's position, so a client can point at the widget that is wrong.
Localization
content: field.blocks({ localized: true })The column moves to the generated translation table, exactly as a localized text field does, and keeps the translation lifecycle, revisions and preview it already had - no new concepts.
What this localizes is the whole list, not the text inside each widget. Each language owns its own list: which widgets are in it, what is in them, and in what order. The two need not correspond at all:
en: core:hero ā core:text ā core:cta
pl: core:hero ā core:cta ā blog:galleryA translated landing page is rarely the same page with different words, so a market can get a different call to action, or none. The flip side is that adding a widget to one language does not add it to the others. A field inside a widget is never localized: true; see Define a widget.
Editing one
The AdminCP form leaves a blocks() field out, and core ships no editor for a record's field. You fill it through the API, or host the visual editor on it yourself with a save route of your own. See Custom editor host. The field's allowed, min and max are checked on that write like any other.
For widgets that belong to a page rather than to a record, such as a settings screen or a forum index, core stores and saves the layout for you. See Editable pages.
Exposing the zone publicly
blocks is publicly exposable, and crosses whole:
publicApi: {
path: "pages",
fields: ["title", "slug", "content", "publishedAt"],
}The public read returns the instances in stored order, each one's data already validated on the way in.
What a zone is not
| Not indexable | A btree index over a JSONB document orders pages by their serialised bytes. indexes refuses it. |
| Not filterable or orderable | For the same reason. |
| Not searchable | Widget text is not indexed for search. |
| Not an AdminCP form field | The generated form and the DataTable both leave it out. |