Skip to Content
AdvancedHeadless mode

Headless mode

If Blok’s built-in chrome doesn’t fit your product, you can use the underlying state and hooks to build your own editor UI from scratch — while still getting drag-and-drop, history, slots, auto-forms, and persistence for free.

The store

Blok’s state lives in a Zustand store. Wrap your custom UI in a BlokStoreProvider:

import { BlokStoreProvider, useData, useActions, useSelection, } from "@useblok/core"; function HeadlessEditor() { return ( <BlokStoreProvider config={config} data={initialData}> <MyCanvas /> <MyInspector /> </BlokStoreProvider> ); }

Everything below the provider can read and mutate the document via hooks — exactly like <Blok> does internally.

Core hooks

useData() // current Data document useConfig() // full Config useSelection() // { selectedId, select(id) } useEditing() // the block whose Edit form is open useHistory() // { canUndo, canRedo } useDirty() // true if changes since last save useViewport() // "desktop" | "tablet" | "mobile" | "fullscreen" useDrillPath() // breadcrumbs when focused into an array/slot useClipboard() // { copy, paste, hasContent } useActions() // { // addBlock, removeBlock, duplicateBlock, // moveBlock, updateBlock, undo, redo, // setRootProps, setViewport, ... // } useKeyboardShortcuts({ onSave }) // register the default shortcut set

Every hook must be called inside a <BlokStoreProvider> (or inside <Blok>, which mounts one).

Direct store access

For less common cases (subscribing outside React, reading in callbacks):

import { useBlokStoreApi } from "@useblok/core"; function Something() { const api = useBlokStoreApi(); const printData = () => { const state = api.getState(); console.log(state.data); }; return <button onClick={printData}>Log data</button>; }

Or use useBlokStore(selector) for a selective subscription:

import { useBlokStore } from "@useblok/core"; function BlockCount() { const count = useBlokStore((s) => s.data.content.length); return <span>{count} blocks</span>; }

Auto-form primitives

Even if you build your own UI, you probably want the same auto-form renderer Blok uses:

import { AutoField, FieldRow, TabsBar, groupFieldsByTab } from "@useblok/core"; import { useConfig, useSelection, useEditing, useActions } from "@useblok/core"; function MyInspector() { const config = useConfig(); const { selectedId } = useSelection(); const editing = useEditing(); const { updateBlock } = useActions(); if (!selectedId || !editing) return null; const component = config.components[editing.type]; const tabs = groupFieldsByTab(component.fields ?? {}, component.tabs); return ( <div> <TabsBar tabs={component.tabs ?? []} /> {Object.entries(component.fields ?? {}).map(([name, field]) => ( <FieldRow key={name} field={field}> <AutoField field={field} name={name} value={editing.props[name]} onChange={(v) => updateBlock(editing.props.id, { [name]: v })} /> </FieldRow> ))} </div> ); }

Selection & editing lifecycle

Two concepts:

  • Selected — the user has clicked a block on the canvas. Shown with an outline.
  • Editing — the block whose Edit form is open on the right.

Usually these are the same block, but they diverge when:

  • You drill into a nested array/slot (selected stays on the outer block; editing is the inner item).
  • You use setEditing(null) to close the form while keeping the selection.

Drag-and-drop

Headless mode does not include drag-and-drop out of the box — you’re building your own canvas. Use @dnd-kit (Blok’s own dep) or any drag library, then call moveBlock / addBlock on drop.

Example projects

  • The demo app in this repo uses the full built-in UI — a good reference for prop shapes.
  • A minimal headless example will live at apps/headless-demo in a future release.
Last updated on