Widgets

The blocks() Field

An ordered list of widget instances stored in one JSONB column, validated against the registry on every write.

Adding a zone to a content type

plugins/example/src/content/page.ts
import { defineContentType, field } from '@vitnode/core/content'

export const pageContentType = defineContentType({
  id: 'example.page',
  tableName: 'example_pages',

  fields: {
    title: field.text({ required: true, minLength: 3, maxLength: 200 }),
    slug: field.slug({ source: 'title' }),
    content: field.blocks({
      allowed: ['core:*', 'example:callout'],
      max: 50,
    }),
  },

  publication: { enabled: true },
})
OptionDefaultWhat it does
allowed"*"Which blocks may be placed here.
max200Most block instances one record may hold.
minnoneFewest block instances.
localizedfalseGive every language its own blocks and order.

min and max count block instances, wherever they sit. A block inside a layout area counts towards the limit; the area holding it does not, so an empty area counts as nothing. Two blocks are two blocks whether they sit side by side in an area or one under the other.

A blocks() field is never required and never nullable - the empty list is what "no blocks" is, 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 block
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 block to a plugin you already trust does.

The shape is a string union on purpose, so a later { categories: [...] } or { excluding: [...] } object can join the array without any existing definition changing.

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": "example:callout",
    "data": { "title": "Heads up", "body": "...", "tone": "info" }
  }
]
KeyRule
idStable and unique within the zone. Never the array index - a reorder must not change what a block is.
typeThe globally unique block id.
dataValidated against that block's own field schema.
variantOptional. The look this instance picked, from the ones its block declares. Absent means "the block'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 block instance or an Area - a small layout container with its own id, a layout of four tokens, and children that are blocks:

{
  "id": "01JC4Z8N9X3R7T2M5Q6K8V4B9F",
  "kind": "area",
  "layout": { "columns": 2, "gap": "md" },
  "children": [
    { "id": "01JC…", "type": "core:text", "data": { "heading": "Left" } }
  ]
}

An Area is told apart by kind: "area", needs no entry in allowed - its children are checked against the same list the zone's own blocks are - and cannot contain another Area. A zone stored before Areas existed is a list of blocks, which is a list of nodes that never nests, so nothing was migrated.

Why one column

A block instance is a document: an id, a type, and whatever that type declares. Splitting it across tables would buy a join per zone, a migration per new block, and an ordering Postgres would have to rebuild on every read. One UPDATE re-orders a whole page, and a revision snapshot of it is a value rather than a graph - which is what makes drafts, revisions and reusable blocks tractable later.

Instance ids

createBlockInstanceId() produces a 26-character Crockford base32 identifier - 10 characters of millisecond timestamp, then 16 of randomness. It sorts by creation time, which is what lets a later merge of two edits order instances it has never seen.

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 URL-safe characters, so an imported document or a reusable block can keep the identity 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 - so a row that exists has already been validated. Nothing re-checks it on read, and the renderer never repeats that schema parse - it only compares the stored shape against the block's current fields, so a definition that moved on since the write cannot reach a component.

The whole request is refused if any instance fails:

RefusedMessage
A block the field does not allowBlock "core:cta" is not allowed in this field.
A block no plugin registersBlock "shop:cart" is not registered. Install or enable the plugin that provides it.
Data the block's fields rejectBlock "core:hero" data is invalid - title: Too big…
Two instances sharing an idBlock instance id "01…" appears more than once.

Errors are keyed by the instance's index, so a client can point at the block 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 block document, not the text inside each block. Each language owns its own list: which blocks are in it, what is in them, and what order they come in. The two need not correspond at all.

en:  core:hero  →  core:text  →  core:cta
pl:  core:hero  →  core:cta   →  blog:gallery

That is deliberate. A translated landing page is rarely the same page with different words - a market may need a different call to action, or no gallery - and treating a zone as one translatable document is what makes that expressible. It is also the only model that reuses VitNode's existing localization: a per-field locale map inside JSON would be a second localization system that the translation table, the revision history and the public locale resolution all know nothing about.

The cost is the other side of the same coin: adding a block to one language does not add it to the others, and there is no "this paragraph is the translation of that paragraph" link between two locales. A field inside a block is therefore never localized: true; see Defining a block.

Editing one

A blocks() field is the storage half, and the Visual Editor is how somebody arranges what goes in it. The two are joined by whatever your page hands the editor and whatever your API does with the payload that comes back - the field's own allowed, min and max are re-checked on the way in either way.

For a zone that is not a column on a record at all - a settings screen, a forum index, anything a page simply has - the storage half is core's, and you declare the zone rather than a field. See Editable Pages.

Exposing the zone publicly

blocks is publicly exposable, and crosses whole:

publicApi: {
  enabled: true,
  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 searchableIndexing block prose into search is its own stage.
Not an AdminCP form fieldThe generated form and the DataTable both leave it out - a zone is arranged on the page by the Visual Editor, not typed into a control.