Developer guide
Documentation
Build a document from typed React widgets, then connect editing to your application.
Install
Published pages need the core renderer and the renderer packages for their registered widgets. A CMS route adds the editor packages.
pnpm add \
@headless-widgets/react \
@headless-widgets/rich-text \
react react-domFor an editable rich-text route, install the full set:
pnpm add \
@headless-widgets/react \
@headless-widgets/editor \
@headless-widgets/rich-text \
@headless-widgets/rich-text-editor \
react react-dom@headless-widgets/editor and@headless-widgets/rich-text-editor out of reader imports. This keeps editor state and Lexical out of the reader dependency graph.Create 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 data. Document version 2 stores block payloads under data; version 1 used props.
import { createWidgetDocument } from '@headless-widgets/react'
import { createRichTextValue } from '@headless-widgets/rich-text'
const page = createWidgetDocument([
{
id: 'intro',
type: 'core/rich-text',
version: 2,
data: createRichTextValue([
{ type: 'heading', level: 1, text: 'Release notes' },
{ type: 'paragraph', text: 'The new editor is available.' },
]),
},
{
id: 'launch-note',
type: 'product/callout',
version: 1,
data: { 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.
import { useState } from 'react'
import { WidgetEditor } from '@headless-widgets/editor'
const [document, setDocument] = useState(page)
<WidgetEditor.Root
registry={registry}
value={document}
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 editorMode="always" />
</WidgetEditor.Root>Use defaultValue when Root owns the document. Usevalue and onCommit for controlled state.onCommit receives the full document plus the change reason and undo state. Set editable={false} to keep the editor shell read-only.
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.
import { createWidgetPatchQueue } from '@headless-widgets/editor'
const patchQueue = createWidgetPatchQueue({
documentId: pageId,
initialRevision,
apply: (transaction) => api.applyWidgetPatches(transaction),
onResult: (result) => {
if (result.status === 'conflict') showConflict(result)
if (result.status === 'rejected') showSaveError(result.message)
},
onError: () => showOfflineState(),
})
<WidgetEditor.Root
registry={registry}
value={document}
onCommit={setDocument}
onPatch={(patches) => patchQueue.enqueue(patches)}
>
<WidgetEditor.Content />
</WidgetEditor.Root>documentId, baseRevision,transactionId, and the complete patch batch. The server must compare and advance the revision, apply the batch, and record the transaction ID in one database transaction. It returns applied,conflict, or rejected. Transport retries reuse the transaction ID. Conflicts stop the queue and retain all unapplied batches for an explicit rebase.Add a typed widget
A widget definition owns validation, a default value, an editing component, a read-only component, and its schema version.
import {
createWidgetRegistry,
defineWidget,
isStringValue,
type JsonObject,
type WidgetEditorProps,
type WidgetRendererProps,
} from '@headless-widgets/react'
interface CalloutData extends JsonObject {
message: string
tone: 'note' | 'warning'
}
function CalloutView({ data }: WidgetRendererProps<CalloutData>) {
return <aside data-tone={data.tone}>{data.message}</aside>
}
function CalloutEditor({ data, update }: WidgetEditorProps<CalloutData>) {
return (
<textarea
value={data.message}
onChange={(event) =>
update({ ...data, message: event.target.value })
}
/>
)
}
const calloutWidget = defineWidget<'product/callout', CalloutData>({
type: 'product/callout',
version: 1,
createDefault: () => ({ message: 'New callout', tone: 'note' }),
validate: (data): data is CalloutData =>
isStringValue(data.message) &&
(data.tone === 'note' || data.tone === 'warning'),
renderer: { component: CalloutView },
editor: { component: CalloutEditor },
})
const registry = createWidgetRegistry([calloutWidget])Increase version when stored data changes and add amigrate(data, fromVersion) function. The registry validates the migrated data before an editor or renderer receives it. A widget can also use renderer.load and editor.load to split either component into a separate chunk.
Add rich text when a block needs it
Rich text is optional. Use editorMode="active" to mount one block editor on demand, or editorMode="always" to keep every editable block and its controls visible across a CMS canvas. Published pages use WidgetDocumentRenderer and never mount editors.
import {
createRichTextBlock,
} from '@headless-widgets/rich-text'
import { richTextEditorWidget } from '@headless-widgets/rich-text-editor'
const registry = createWidgetRegistry([
richTextEditorWidget,
heroWidget,
calloutWidget,
])
const intro = createRichTextBlock('intro', [
{ type: 'heading', level: 1, text: 'Hello' },
{ type: 'paragraph', text: 'Keep related prose together.' },
])richTextRendererWidget from@headless-widgets/rich-text in a reader registry and the definition named richTextEditorWidget from@headless-widgets/rich-text-editor in an editor registry. Do not register both definitions together.Add rich-text controls
dispatchRichTextCommand sends a typed command to the mounted rich-text editor and returns false when no editor is active. Mark an external toolbar with data-rich-text-toolbar so moving focus into it does not finish the block edit.
import {
dispatchRichTextCommand,
} from '@headless-widgets/rich-text-editor'
<div data-rich-text-toolbar role="toolbar" aria-label="Text formatting">
<button onClick={() => dispatchRichTextCommand('bold')} type="button">
Bold
</button>
<button
onClick={() => dispatchRichTextCommand('heading-2')}
type="button"
>
Heading
</button>
<button
onClick={() => dispatchRichTextCommand('link', 'https://example.com')}
type="button"
>
Link
</button>
</div>Render without controls
WidgetDocumentRenderer renders the same validated document without selection state, toolbars, or editor controls. Unknown and invalid blocks remain in the JSON and render configurable fallbacks.
import { WidgetDocumentRenderer } from '@headless-widgets/react'
export function PublishedPage({ document }: Props) {
return (
<WidgetDocumentRenderer
document={document}
registry={registry}
unknownFallback={(type) => <p>Unavailable block: {type}</p>}
/>
)
}Open the live examples to edit widget documents, or read the package guides on GitHub for the complete public API.