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.
A widget is a page section somebody can place, in an order they choose: a hero, a paragraph of prose, a call to action, "the six newest articles". A plugin defines it once - the fields it holds and the React component that renders it - and every content type or editable page in the installation can offer it.
Plugin
↓ defines widgets (defineWidget)
Widget Registry
↓ allowed by a blocks() field or defineEditablePage()
Content record / Page layout
↓
ContentZone
↓
ContentRenderer / Visual Editor (Edit widgets)What a widget is made of
| Part | What it is |
|---|---|
| Id | hero, written without a namespace. The registry prefixes the plugin's own, so it is stored as core:hero. |
| Fields | Content Engine field descriptors - the same field.text(), field.enum() and friends a content type uses. There is no second schema system. |
| Component | One public React component. It receives the data the record stores for that instance. |
| Schema | Derived from fields at the write boundary. You never write it, and a public render never builds it. |
What a widget is not
A widget is not a content type. It has no table, no id of its own in the database, no permissions and no API routes. It is a value that lives inside a record or a page layout - which is exactly why re-ordering a page is one UPDATE and not a migration.
Visual editing
Widgets power in-place visual editing. Wrap your page's content zones in <EditablePage>:
<EditablePage
adapter={adapter}
canEdit={canEdit}
layout={layout}
page={settingsPage}
>
<ContentZone id="before-profile" />
<ProfileForm />
<ContentZone id="sidebar" />
</EditablePage>There is no button to write. Edit widgets appears in the user menu in the site header whenever a page like this one is on screen and canEdit says yes. When an authorized moderator clicks it:
- Drag and drop: Rearrange widgets or drag new widgets into allowed zones.
- Properties panel: Configure widget settings, variants, and Layout Areas.
- Zero visitor footprint: The editor chunk is lazily loaded on click and never ships to ordinary visitors.
- Layout persistence: Overrides save directly to
core_page_layouts—see Editable Pages. - Room, not overlap: The whole site slides over for the sidebar, so nothing you are arranging ends up underneath it.
How widgets reach an application
Nothing is scanned and nothing is queried. A plugin exports a widgets module; VitNode's Vite plugin sees it and writes src/widgets.gen.ts, which builds the registry and registers it.
import {
createWidgetRegistry,
setDefaultWidgetRegistry,
} from '@vitnode/core/widgets'
import { widgets as widgets0 } from '@vitnode/core/widgets/built-in'
import { widgets as widgets1 } from '@vitnode/example/widgets'
export const pluginWidgets = [widgets0, widgets1]
export const widgetsRegistry = createWidgetRegistry(pluginWidgets)
setDefaultWidgetRegistry(widgetsRegistry)Installing a plugin is the whole registration step. Removing it takes its widgets with it, and a page that still references one keeps rendering everything else - see Rendering.
A registry is a plain value, not a service: createWidgetRegistry returns an isolated one, and every consumer takes it as an argument. The last line installs it as the process default, which is a convenience for a renderer used without a registry prop. Two applications in one process each create their own and pass it explicitly.
Import src/blocks.gen.ts only from a route that renders widgets - it statically imports every plugin's widget components, so a root module that touches it makes every visitor pay for all of them. The API never needs it: it builds its own registry from the same plugins when it boots, which is what validates every write.
Where validation happens
write (untrusted) → full schema check, defaults applied → JSONB
read → nothing
render → structural check, every modeWidget data is checked in full once, where it arrives: every create and update runs each instance through its widget's own schema before a row is written, and ContentRenderer never repeats that parse. What it does repeat, in every mode, is a cheap structural comparison of the stored object against the widget's current fields - because the write was validated against the definition the widget had then, and a plugin can rename a field afterwards. A widget whose stored shape no longer matches is skipped rather than handed to a component that would reach for a key that is gone. Rendering has the detail.
Widgets that ship with core
| Id | What it renders |
|---|---|
core:hero | A page heading with an optional eyebrow, lead paragraph and one link. |
core:text | Plain paragraphs of prose. Blank lines separate paragraphs; nothing is parsed as markup. |
core:cta | A short call to action with one link. |
Guides
Defining a Widget
defineWidget, fields, namespacing and typed component props
The blocks() Field
Storing an ordered zone on a content type, and restricting what may go in it
Widget Variants
One widget, several looks - stored beside the data instead of inside it
Layout Areas
Widgets side by side in up to four columns, without a columns container
Rendering
ContentRenderer, missing widgets, and why a widget costs no extra request
Content Zones
Naming a place on a page where widgets may appear, including around locked application UI
Editable Pages
Declaring a page's zones once, so the people who run the site rearrange them on the page itself
Visual Editor
Turn a public page into an interactive editing surface with drag and drop and a widget sidebar
End-to-end Example
From an empty plugin to a rendered page, in seven steps