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-dom

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 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,
  })
}
Apply each patch batch and its document revision atomically on the server. Unchanged block records can stay cached.

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])
Importing a widget does not mutate a global registry. Pass the same registry through validation, editing, migration, and rendering.

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.