Install
Install the block engine beside React. Add the rich-text package only when the registry needs prose.
pnpm add @headless-widgets/react @headless-widgets/rich-text react react-domCreate a block document
The canonical value is versioned JSON with an ordered block list. Every block has a stable ID, type, schema version, and JSON-safe props.
import { createWidgetDocument } from '@headless-widgets/react'
const page = createWidgetDocument([
{
id: 'intro',
type: 'core/rich-text',
version: 1,
props: { editorState: savedIntroState },
},
{
id: 'launch-note',
type: 'product/callout',
version: 1,
props: { message: 'Now available', tone: 'note' },
},
])Compose the editor
Root owns the document and history. Content renders the ordered block list. Toolbar and insert buttons are unstyled React components, so the CMS keeps its own interface.
const [document, setDocument] = useState(page)
<WidgetEditor.Root
registry={registry}
value={document}
onPatch={(patches) => blockStore.applyPatches(patches)}
onCommit={setDocument}
>
<WidgetEditor.Toolbar aria-label="Add content">
<WidgetEditor.InsertButton widgetType="core/rich-text">
Add text
</WidgetEditor.InsertButton>
<WidgetEditor.InsertButton widgetType="product/callout">
Add callout
</WidgetEditor.InsertButton>
</WidgetEditor.Toolbar>
<WidgetEditor.Content />
</WidgetEditor.Root>Persist patches
onPatch emits complete block operations, not character diffs. A set operation writes one block. remove deletes one record. order updates the small block-ID manifest.
type WidgetPatch =
| { op: 'set'; block: WidgetBlock }
| { op: 'remove'; blockId: string }
| { op: 'order'; blockIds: readonly string[] }
function applyPatches(patches: readonly WidgetPatch[]) {
return api.updatePage({
pageId,
expectedRevision,
patches,
})
}Add a typed widget
A widget definition owns validation, a default value, an editing component, a read-only component, and its schema version.
interface CalloutValue extends JsonObject {
message: string
tone: 'note' | 'warning'
}
const callout = defineWidget<'product/callout', CalloutValue>({
type: 'product/callout',
version: 1,
createDefault: () => ({ message: 'New callout', tone: 'note' }),
validate: (value): value is CalloutValue =>
typeof value.message === 'string' &&
(value.tone === 'note' || value.tone === 'warning'),
render: ({ value }) => (
<aside data-tone={value.tone}>{value.message}</aside>
),
edit: ({ update, value }) => (
<textarea
value={value.message}
onChange={(event) =>
update({ ...value, message: event.target.value })
}
/>
),
})
const widgets = createWidgetRegistry([callout])Add rich text when a block needs it
Rich text is optional. Idle blocks render lightweight React elements. Lexical loads only when a rich-text block enters editing mode, and the document keeps one active editor at a time.
import {
createRichTextWidget,
richTextWidget,
} from '@headless-widgets/rich-text'
const registry = createWidgetRegistry([
richTextWidget,
heroWidget,
calloutWidget,
])
const intro = createRichTextWidget('intro', [
{ type: 'heading', level: 1, text: 'Hello' },
{ type: 'paragraph', text: 'Keep related prose together.' },
])Render without controls
WidgetDocumentView renders the same validated document without selection state, toolbars, or editor controls.
export function PublishedPage({ document }: Props) {
return (
<WidgetDocumentView
document={document}
registry={registry}
unknownFallback={(type) => <p>Unavailable block: {type}</p>}
/>
)
}Use the Markdown package for single documents
@headless-markdown/react remains useful for a Markdown field, article body, or other document that should save as one value. Use the widget package when page sections need independent schemas and writes.
pnpm add @headless-markdown/react
<MarkdownEditor.Root value={markdown} onChange={setMarkdown}>
<MarkdownEditor.Content />
</MarkdownEditor.Root>Open the live examples to edit documents, or read the repository package guides for the complete public API.