Documentation

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

plugins/newsroom/src/content/page.ts (excerpt)
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().

OptionDefaultWhat it does
allowed"*"Which widgets may be placed here.
max200Most widgets one record may hold.
minnoneFewest widgets.
localizedfalseGive 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 two

A 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 NULL

holding 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" }
  }
]
KeyRule
idStable and unique within the zone. Never the array index, since a reorder must not change what a widget is.
typeThe globally unique widget id, such as core:hero.
dataValidated against that widget's own fields.
variantOptional. 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:

RefusedMessage
A widget the field does not allowBlock "core:cta" is not allowed in this field.
A widget no plugin registersBlock "shop:cart" is not registered. Install or enable the plugin that provides it.
Data the widget's fields rejectBlock "core:hero" data is invalid - title: Too big…
Two instances sharing an idBlock 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:gallery

A 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 indexableA btree index over a JSONB document orders pages by their serialised bytes. indexes refuses it.
Not filterable or orderableFor the same reason.
Not searchableWidget text is not indexed for search.
Not an AdminCP form fieldThe generated form and the DataTable both leave it out.