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 setEvery 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
demoapp in this repo uses the full built-in UI — a good reference for prop shapes. - A minimal headless example will live at
apps/headless-demoin a future release.