Defining a Widget
defineWidget takes an id, Content Engine fields and one React component, and infers the widget's data type from the fields.
One call
import type { WidgetComponentProps, WidgetData } from '@vitnode/core/widgets'
import { defineWidget } from '@vitnode/core/widgets'
import { field } from '@vitnode/core/content'
const calloutFields = {
body: field.textarea({ required: true, minLength: 1, maxLength: 600 }),
title: field.text({ required: true, minLength: 1, maxLength: 120 }),
tone: field.enum({
defaultValue: 'info',
values: ['info', 'success', 'warning'],
}),
}
type CalloutData = WidgetData<typeof calloutFields>
const Callout = ({ data }: WidgetComponentProps<CalloutData>) => (
<aside>
<h2>{data.title}</h2>
<p>{data.body}</p>
</aside>
)
export const calloutWidget = defineWidget({
component: Callout,
description: 'A short highlighted note in one of three tones.',
fields: calloutFields,
id: 'callout',
name: 'Callout',
})WidgetData<typeof calloutFields> is the inferred data type: title and body are string, tone is "info" | "success" | "warning" | undefined because it has a default. Nothing is written twice.
Component props
interface WidgetComponentProps<TData> {
/** The instance's own stable id - not the widget's. */
widgetId: string
data: TData
index: number
type: string
variant?: string
}data has already been through the widget's schema when the component runs, so a component never checks for missing fields. variant is the look this instance picked, and is undefined for a widget that offers none.
Two looks, one widget
A widget that should render the same content in more than one shape declares its shapes instead of growing a style field:
export const featuresWidget = defineWidget({
component: Features,
defaultVariant: 'grid',
fields: featuresFields,
id: 'features',
variants: [{ id: 'grid' }, { id: 'list' }, { id: 'compact' }],
})The chosen variant is stored beside the instance's data, never inside it, and the editor grows a picker for it with no further work. See Block Variants for the resolution rules and what happens to a stored variant a block stops offering.
Ids are namespaced for you
Write id: "callout". The registry prefixes the namespace of the plugin that registered it, derived from the last segment of the package name:
| Plugin | Block id | Stored as |
|---|---|---|
@vitnode/core | hero | core:hero |
@vitnode/blog | latest-posts | blog:latest-posts |
@acme/page-builder | pricing | page-builder:pricing |
Writing the namespace yourself is an error - a block would otherwise be addressable under two names, and content written under the wrong one would be unrenderable.
Two packages whose names end in the same segment (@vitnode/blog and @acme/blog) collide. The registry refuses to build and names both; set namespace on one of their blocks modules to settle it.
Which fields a block may hold
| Kind | Supported | Why |
|---|---|---|
field.text(), field.textarea() | ✅ | Plain strings, edited in the properties panel. |
field.number(), field.boolean() | ✅ | |
field.enum(), field.dateTime() | ✅ | |
field.group() | ✅ | Nested objects are ordinary JSON. |
field.file(), field.relation(), field.user() | Not yet | A reference is a row identifier the owning record has to pin against deletion, and a value inside a JSON document has nowhere to put a foreign key. |
field.repeatable() | Not yet | Its rows are rows on a generated child table, identified by a database id a JSON document does not have. |
field.slug() | No | A slug is a record's address, and a block is not addressable on its own. |
field.blocks() | Not yet | Nested blocks need depth limits, their own allowlist and cycle detection first. |
Every one of those rules lives in one table, blockFieldKindRefusal, and the error a refused field produces quotes the reason from it.
Media and relations are a limitation of today, not of the model
The serialized shape - { id, type, data }, with data holding this field map's values - does not change when these land: a file field would store a core_files.id exactly as a file column does. What is missing is the lifecycle around it. A content column pins its file with ON DELETE RESTRICT, which is what makes the Files screen answer 409 instead of leaving a published page pointing at bytes that are gone; a value inside JSONB has no foreign key to carry that.
The intended solution is to extract a widget's references out of its data on write - the field map already says which leaves are references - and pin them in a reference table the same way, so deletion, cleanup and onDelete keep working. Until that exists, storing a bare id in JSON would be a foreign key with none of the guarantees of one, which is worse than not supporting it. Put the image on the record for now and let the widget choose how to present it.
Localization
A widget's fields are never localized: true. There is no translation row for them to live in, and per-field locale maps inside JSON would be a second localization system that revisions, preview and the public locale resolution know nothing about.
The zone is what translates:
content: field.widgets({ localized: true })This is document-level localization, not translated field values: each language gets its own widgets, in its own order, and the two need not correspond at all.
en: Hero → Text → CTA
pl: Hero → CTA → GallerySee The blocks() field.
Editing a widget's text in the properties panel changes nothing about this. What somebody types goes into the data of the snapshot the page mounted for the locale it is showing, and it is saved by whoever already owned that locale's zone.
Validating data by hand
A definition is metadata: an id, the field map and the component. It carries no schema of its own, which is what keeps defineWidget - and therefore every public page that renders a widget - free of a validation library. The schema is derived from the fields on demand:
import { parseWidgetData, widgetDataSchema } from '@vitnode/core/widgets'
const data = parseWidgetData(calloutWidget, { title: 'Heads up', body: '...' })
// { title: "Heads up", body: "...", tone: "info" }
widgetDataSchema(calloutWidget).safeParse(input)parseWidgetData applies defaults and throws a BlockError naming the field when the value does not fit; its return type is inferred from the definition's field map. widgetDataSchema is the underlying Zod object, built once per definition and cached.
A variant is not part of that schema, and neither is the arrangement of the widgets around this one - those are the instance's variant and its Area's layout. The rule of thumb: data is what the widget says, variant is how this instance says it, Area.layout is how widgets sit together.
The Content Engine calls these for you on every write. You need them only when you are producing widget data outside the API - a seed, a migration, an importer.
Widgets Overview
Plugins define widgets, VitNode collects them into a registry, and content types or editable page zones store an ordered list of widgets that moderators can visually edit in place.
The blocks() Field
An ordered list of widget instances stored in one JSONB column, validated against the registry on every write.