Skip to content
FrameworkJavaScript

Blocks API: insert, move, delete, and render blocks

Manage blocks in the editor: create, delete, update, and reorder content.

Last updated Oct 5, 2026Edit this page on GitHub

Reaching the editor instance

The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.

TypeScript
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;

// Call any API method on it.
editor.caret.setToLastBlock('end');

Methods

blocks.clear()

Promise<void>

Remove all blocks from the editor.

When to use

Same as the top-level clear(), exposed on the blocks module. Use it when you already hold editor.blocks.

TypeScript
await editor.blocks.clear();
// All content removed; editor keeps one empty paragraph

blocks.render(data)

Promise<void>

Render passed JSON data as blocks, replacing the current document. It is echo-safe: when the incoming document is structurally equal to the current saved content (time/version are ignored), the call is a caret-preserving no-op. So the data → render → onSave → setState → data round-trip needs no dedupe on your side.

It accepts the loose wire shape (LooseOutputData). The editor deep-clones the data, so the passed object is never mutated or retained. You can pass frozen store state (Redux, Immer) directly.

A render that replaces the document also clears undo history, so undo cannot bring the previous document back.

When to use

Replaces the whole document. Echoing the editor's own output back is a caret-preserving no-op, so you need no dedupe on your side. To add blocks without clearing, use insert() or insertMany().

TypeScript
const data = {
  blocks: [
    { id: '1', type: 'paragraph', data: { text: 'Hello World' } },
    { id: '2', type: 'header', data: { text: 'Title', level: 1 } }
  ]
};
await editor.blocks.render(data);

blocks.renderFromHTML(data)

Promise<void>

Render HTML string as blocks by converting it to block format.

When to use

Useful for importing legacy HTML, but the result is only as good as the tools' paste handlers. When you control the source, JSON via render() keeps more fidelity.

TypeScript
const html = '<h1>Title</h1><p>Hello World</p>';
await editor.blocks.renderFromHTML(html);
// HTML is converted to appropriate blocks

blocks.importMarkdown(md, options?)

Promise<OutputData>

Converts a Markdown string to blocks and renders them. This REPLACES the current document, because it calls blocks.render() internally.

The converter is lazy-loaded on the first call. The resolved OutputData is the document that was rendered. options is a MarkdownImportConfig (tool mapping, GFM toggle, micromark/mdast extensions).

To add blocks instead of replacing them, use markdownToBlocks() from the standalone @bloklabs/core/markdown subpath together with blocks.insertMany().

Parameters

ParameterTypeRequiredDefaultDescription
mdstringRequired—

Markdown source string.

optionsMarkdownImportConfig—undefined

Tool mapping, GFM toggle, and micromark/mdast extensions.

TypeScript
const data = await editor.blocks.importMarkdown('# Title\n\n- one\n- two');
// The whole document is replaced; data is the rendered OutputData

blocks.exportMarkdown()

Promise<string>

Serializes the current document to Markdown. It is the outbound twin of importMarkdown. Blocks are read through the Saver, so the output reflects the saved (validated) document, not the raw DOM.

The promise resolves to '' when there is nothing to save. Blocks owned by a table cell are serialized inside the pipe table, not repeated as loose lines.

What Markdown cannot express is degraded.

  • Table colspan/rowspan and heading columns are dropped.
  • A table with no heading row gets an empty header row, because GFM requires one.
TypeScript
const md = await editor.blocks.exportMarkdown();
// → '# Title\n\n- one\n- two'

blocks.delete(index?, setCaret?)

Promise<void>

Remove the block at the specified index, or current block if no index provided.

When to use

Without an index it deletes the focused block. Deleting the last remaining block automatically inserts a fresh empty paragraph, so the editor is never left without a caret target.

Parameters

ParameterTypeRequiredDefaultDescription
indexnumber—current block index

Index of the block to delete.

setCaretboolean—true

Whether to move the caret to the surviving block after deletion. Pass false to avoid stealing the user's caret during programmatic deletion.

TypeScript
// Delete current block
await editor.blocks.delete();

// Delete block at index 0
await editor.blocks.delete(0);

// Delete without moving the user's caret (programmatic deletion)
await editor.blocks.delete(0, false);

blocks.move(toIndex, fromIndex?)

void

Moves a block to a new position. If fromIndex is not provided, moves the current block.

When to use

toIndex is in post-removal index space. For a forward move, subtract 1 or the block lands one slot too far.

TypeScript
// Move current block to top
editor.blocks.move(0);

// Move block from index 2 to index 0
editor.blocks.move(0, 2);

blocks.getBlockByIndex(index)

BlockAPI | undefined

Get the BlockAPI object for the block at the specified index.

When to use

Returns undefined for out-of-range indexes. Calling a method on an undefined result throws, so check the return value first.

TypeScript
const block = editor.blocks.getBlockByIndex(0);
if (block) {
  console.log(block.id, block.name);
}

blocks.getById(id)

BlockAPI | null

Get the BlockAPI object for the block with the specified ID.

When to use

The stable way to reference a block across edits, since indexes shift. Returns null if the id no longer exists.

TypeScript
const block = editor.blocks.getById('block-123');
if (block) {
  await editor.blocks.update(block.id, { text: 'New content' });
}

blocks.getCurrentBlockIndex()

number

Get the index of the currently focused block.

When to use

Reflects the block holding the caret. Returns -1 when nothing is focused, so check the value before using it as an index.

TypeScript
const index = editor.blocks.getCurrentBlockIndex();
console.log('Current block index:', index);

blocks.getBlockIndex(blockId)

number | undefined

Get the index of a block by its ID.

When to use

Finds where a known block sits right now. Pair it with getById() when you have an id but need a position.

TypeScript
const index = editor.blocks.getBlockIndex('block-123');
if (index !== undefined) {
  console.log('Block is at index:', index);
}

blocks.getBlockByElement(element)

BlockAPI | undefined

Get the BlockAPI object for the block containing the given HTML element.

When to use

Bridges from a raw DOM node (e.g. a click target) back to its block. Useful in custom event handlers.

TypeScript
document.addEventListener('click', (e) => {
  const block = editor.blocks.getBlockByElement(e.target);
  if (block) {
    console.log('Clicked on block:', block.id);
  }
});

blocks.scrollToBlock(id)

void

Scrolls a block into view, selects it, pulses the arrival highlight and announces the navigation to assistive tech. It is the public counterpart of the boot-time URL-hash scroll. It does nothing when no block with that id is in the document.

A new jump replaces any blocks that are already selected. Pass { select: false } as the second argument to jump without selecting, as in-page navigation such as a table of contents does: a selected block is deleted by the next Backspace.

Framework adapters that mount into a detached holder (React/Vue/Angular) render seeded content before it joins the page, so the boot hash scroll defers. @bloklabs/react drains it automatically once the holder connects.

For deep linking, call this method yourself after the editor is ready.

TypeScript
editor.blocks.scrollToBlock(nodeId);

blocks.getChildren(parentId)

BlockAPI[]

Get all child blocks of a parent container block.

When to use

Returns the direct children of a container block (a column, toggle, or database). Empty array if it has none.

TypeScript
const children = editor.blocks.getChildren('parent-block-id');
children.forEach(child => {
  console.log('Child:', child.id);
});

blocks.setBlockParent(blockId, parentId)

void

Reparents a block. It updates the block's parentId and the parent's contentIds through core's single reparent chokepoint.

  • Pass null to move the block back to the root level.
  • An unknown blockId does nothing and logs a warning.
  • A parentId that would make the block a descendant of itself throws.

The block's place in the document order follows its new parent.

  • If the block already sits inside the parent's subtree, it does not move. That means it comes right after the parent, or right after one of the parent's descendants.
  • Otherwise the block moves to the end of the parent's subtree. Its own children move with it.
  • A table or a database places its own children, so a block that joins one does not move.
  • A block that leaves for the root or for one of its ancestors moves, with its children, to just after the subtree it left, unless it already sits there. To move several siblings out of a container and keep their order, move the last one first.
TypeScript
// Move a block into a container block
editor.blocks.setBlockParent('child-block-id', 'parent-block-id');

// Move it back to the root level
editor.blocks.setBlockParent('child-block-id', null);

blocks.insertInsideParent(parentId, insertIndex, childData?, toolName?)

BlockAPI

Inserts a block as a child of parentId atomically. The creation and the parent assignment are grouped into ONE undo entry, so a single Cmd+Z removes it completely. That is better than insert() followed by a reparent, which is two entries.

  • insertIndex is the FLAT document index where the child should appear. It is clamped into the parent's subtree.
    • An index at or before the parent becomes the slot right after the parent.
    • An index past the end of the subtree becomes the end of the subtree.
  • toolName picks the child's block tool and defaults to config.defaultBlock.
    • A tool that is restricted inside table cells is demoted to the default block when the new child would land inside one.
    • An unregistered name throws before anything is written.
  • childData defaults to { text: '' } for the default block.
    • For any other toolName it defaults to {}, which lets the tool apply its own defaults.
TypeScript
const parentIndex = editor.blocks.getBlockIndex('parent-block-id') ?? 0;
const child = editor.blocks.insertInsideParent(
  'parent-block-id',
  parentIndex + 1,
  { text: 'Nested content' },
);

// A TYPED child in the same single undo entry — no insert-then-reparent
editor.blocks.insertInsideParent(
  'parent-block-id',
  parentIndex + 1,
  { text: 'Nested heading', level: 2 },
  'header',
);

blocks.insertAt(type?, data?, options?)

BlockAPI

Inserts a new block at a place named by its parent and its siblings, not by a flat index. It is ONE undo entry.

  • position is 'start', 'end' (the default), { before: id } or { after: id }.
    • { after: id } lands after that sibling's whole subtree, not among its children.
  • parentId names the parent; null is the root.
    • When you omit it, before/after use the sibling's parent, and 'start'/'end' use the root.
  • replace: id puts the new block in that block's place. It keeps the parent; the children follow the turn-into rules. It cannot be combined with parentId or position.
  • focus defaults to false, so a programmatic insert does not move the current block.
  • A tool the parent does not allow is demoted, as with insertInsideParent.

It throws, and changes nothing, when the parent, the sibling or the replaced block is not found, or when the sibling is not a child of parentId.

TypeScript
// Last child of a toggle
editor.blocks.insertAt('paragraph', { text: 'Inside' }, { parentId: toggleId });

// Right after a block's whole subtree, at the block's own level
editor.blocks.insertAt('header', { text: 'Next', level: 2 }, { position: { after: blockId } });

// First block of the document
editor.blocks.insertAt('paragraph', { text: 'Top' }, { parentId: null, position: 'start' });

blocks.moveTo(id, target)

void

Moves a block, with its whole subtree, to a place named by its parent and its siblings. It is ONE undo entry, even when the parent changes.

  • position works as in insertAt. { after: id } lands after that sibling's whole subtree.
  • parentId names the new parent; null is the root. When you omit it, before/after use the sibling's parent, and 'start'/'end' use the root.

It throws, and changes nothing, when:

  • a block is not found, or the sibling is not a child of parentId;
  • the target is the block itself or inside its own subtree;
  • the move would leave or enter a container that owns its children (a table, a column list), a column, or a table cell;
  • the new parent does not allow the tool (childTools).
TypeScript
// Into a toggle, as its first child
editor.blocks.moveTo(blockId, { parentId: toggleId, position: 'start' });

// Out of a container, right after it
editor.blocks.moveTo(childId, { position: { after: containerId } });

blocks.getBlocksCount()

number

Get the total number of blocks in the editor.

When to use

Counts every block including nested children (columns, toggles, table cells), not just top-level blocks. Use it for bounds before an index-based insert() or move().

TypeScript
const count = editor.blocks.getBlocksCount();
console.log('Total blocks:', count);

blocks.insert(type?, data?, config?, index?, needToFocus?, replace?, id?, tunes?, origin?)

BlockAPI

Insert a new block with full control over its properties and position.

When to use

Every parameter is optional. Call insert() for an empty paragraph, or pass type/data/index for full control. Pass replace: true to overwrite the block at index.

Parameters

ParameterTypeRequiredDefaultDescription
typestring—config.defaultBlock

Tool name to instantiate.

dataBlockToolData—{}

Initial tool data for the new block.

configToolConfig—{}

Ignored. It is accepted only to keep the positional signature stable. The editor binds it to _config and never reads it. The block-manager insert options carry no config field, so the created block still uses the editor-level tool config from config.tools.

Pass undefined.

indexnumber—current block index + 1

Position to insert the block at.

The new block also takes the parent that this position implies. That holds when index is left out, too.

  • Right after a container that has children, it becomes the container's first child.
  • Anywhere else, it gets the same parent as the block that follows it. So between two children of a toggle, it becomes a child of that toggle.
  • At the start or the end of the document, it is a root block.
  • Right after a collapsed toggle that has children, it goes after those hidden children instead. The rules above then pick its parent there, and its final index can be larger than index.
  • Right after a block inside a column, it joins that column.

A column row never takes the block this way. Right after a table or a database, or a block inside one, no parent is inferred. The block is a root block, unless it comes right after a block in a column. With replace: true, no parent is inferred.

The parent's childTools rules apply. A tool the parent does not allow is swapped for one it allows.

needToFocusboolean—true

Whether to move focus to the inserted block. Pass false to insert without moving the caret.

replaceboolean—false

Replace the existing block at index instead of inserting around it.

idstring—auto-generated

Custom id for the new block.

tunes{ [name: string]: BlockTuneData }—undefined

Optional tune data applied at creation, keyed by tune name.

originBlockOrigin—'api'

Why the block is being created. It is handed to the tool constructor as origin. That is how a container tool tells a genuine creation (seed default children) from a re-materialisation, such as a document load, an undo/redo replay or a paste (never seed).

  • Pass 'user' when the insert comes from your own insertion UI: a custom toolbar, slash menu or keyboard shortcut.
    • Then Blok's own containers (column_list, column) and your own behave the same as they do for the built-in menu.
  • Leave it out for programmatic inserts and refetches.
    • The 'api' default is still a creation, but it is never mistaken for a user gesture.

Errors

  • No type is given and no defaultBlock is configured.

    Could not insert Block. Tool name is not specified.

    Pass an explicit type, or set defaultBlock in the editor config.

  • The resolved tool name is not registered in the editor.

    Could not compose Block. Tool «<type>» not found.

    Register the tool in the editor's tools config before inserting a block of that type.

  • replace: true is passed but no block exists at index.

    Could not replace Block at index <index>. Block not found.

    Check index against blocks.getBlocksCount() before calling with replace: true.

TypeScript
// Insert after the current block with the default type
const block = editor.blocks.insert();
// → BlockAPI { id: 'kP3xQ...', name: 'paragraph', ... }

// Insert paragraph with data at index 0
const block = editor.blocks.insert('paragraph', { text: 'Hello' }, undefined, 0);

// Insert with custom ID
const block = editor.blocks.insert('header', { text: 'Title' }, undefined, undefined, undefined, undefined, 'custom-id');

// From your own slash menu / toolbar: declare the user gesture so a container
// tool seeds its default children (see BlockOrigin on the tool contract)
const block = editor.blocks.insert('column_list', undefined, undefined, index, undefined, undefined, undefined, undefined, 'user');

blocks.insertMany(blocks, index?)

BlockAPI[]

Insert multiple blocks at once. When the index is omitted, the blocks are appended at the end of the document.

When to use

Bulk-insert in one operation, and one undo step. That is far cheaper than looping insert() for large pastes or imports.

Parameters

ParameterTypeRequiredDefaultDescription
blocksOutputBlockData[] | LooseOutputBlockData[]Required—

The blocks to insert. Loose wire blocks are accepted. A null data becomes {}, and a null/empty id gets a generated one.

indexnumber—end of document

Position to insert at. When omitted, defaults to appending at the end of the document.

Errors

  • The provided index is negative.

    Index should be greater than or equal to 0

    Pass an index of 0 or greater, or omit it to append.

TypeScript
const blocksToInsert = [
  { id: '1', type: 'paragraph', data: { text: 'First' } },
  { id: '2', type: 'paragraph', data: { text: 'Second' } }
];
const inserted = editor.blocks.insertMany(blocksToInsert, 0);
console.log('Inserted:', inserted.length, 'blocks');

blocks.composeBlockData(toolName)

Promise<BlockToolData>

Create empty block data for the specified tool type.

When to use

Builds a tool's default data without inserting anything. Useful when you need a valid empty payload for insert() or update().

Errors

  • toolName is not a registered tool.

    Block Tool with type "<toolName>" not found

    Register the tool in the editor's tools config first.

TypeScript
const emptyData = await editor.blocks.composeBlockData('paragraph');
// Returns: { text: '' } or appropriate empty state for the tool

blocks.update(id, data?, tunes?)

Promise<BlockAPI>

Updates a block's data and/or tunes. The data update is applied IN PLACE when the block's tool implements setData(newData) on its prototype. Every React/Vue/Angular block does, as do the built-in header, list, code, toggle and table tools.

In place means the same block instance, the same DOM holder and the same mounted component keep living. So ephemeral tool state, adopted child blocks and the caret all survive. That is what makes update() safe to call on every keystroke, for example when renaming a card while the user types.

Three cases fall back to recomposing the block:

  • tools without setData
  • a tool whose setData returns false (it needs a different DOM shape)
  • any call that passes tunes

Recomposing means a fresh tool instance replaces the old one, which is destroyed.

When to use

Patches data/tunes in place, keeping the block id and type. To change the type, use convert() instead.

Parameters

ParameterTypeRequiredDefaultDescription
idstringRequired—

Id of the block to update.

dataPartial<BlockToolData>—undefined

Partial data merged into the block's existing data.

tunesRecord<string, BlockTuneData>—undefined

Tune data merged into the block's existing tunes.

Errors

  • No block exists with the given id.

    Block with id "<id>" not found

    Confirm the id with blocks.getById() before calling update().

TypeScript
// Update block data
const block = await editor.blocks.update('block-123', { text: 'New text' });

// Update with tunes
const block = await editor.blocks.update('block-123', undefined, { alignment: 'center' });

// Guard against an id that no longer exists (e.g. the block was deleted
// concurrently) instead of letting the rejection surface unhandled
try {
  await editor.blocks.update(staleId, { text: 'New text' });
} catch {
  console.warn('Block was already removed, skipping update');
}

blocks.convert(id, newType, dataOverrides?)

Promise<BlockAPI>

Convert a block to a different type. Both tools must support conversion config.

When to use

Both the source and target tools must declare conversionConfig, otherwise the call throws. Preserves text where possible.

Parameters

ParameterTypeRequiredDefaultDescription
idstringRequired—

Id of the block to convert. Its tool must declare conversionConfig.export.

newTypestringRequired—

Name of the registered tool to convert to. Its tool must declare conversionConfig.import.

dataOverridesBlockToolData—undefined

Data fields to overwrite on the resulting block after conversion.

Errors

  • No block exists with the given id.

    Block with id "<id>" not found

    Confirm the id with blocks.getById() before calling convert().

  • newType is not a registered tool.

    Block Tool with type "<newType>" not found

    Register the target tool in the editor's tools config.

  • The source tool has no conversionConfig.export, the target tool has no conversionConfig.import, or neither does.

    Conversion from "<sourceType>" to "<newType>" is not possible. <ToolName(s)> tool(s) should provide a "conversionConfig"

    Add a conversionConfig to whichever tool is missing one, or convert through an intermediate tool that supports both directions.

TypeScript
// Convert paragraph to header
const headerBlock = await editor.blocks.convert('block-123', 'header', { level: 2 });

// Convert with data overrides
const headerBlock = await editor.blocks.convert('block-123', 'header', { text: 'New Title', level: 1 });

// Not every pair of tools supports conversion — guard it rather than
// assuming the target type is always convertible
try {
  await editor.blocks.convert('block-123', 'table');
} catch (error) {
  console.warn('Conversion not supported between these tools:', error);
}

blocks.splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex)

BlockAPI

Atomically split a block by updating the current block and inserting a new block. Both operations are grouped into a single undo entry.

When to use

Use it for caret-aware splits, for example Enter mid-text. The update and the insert land as a single undo entry.

TypeScript
// Split a paragraph at cursor position
const newBlock = editor.blocks.splitBlock(
  'current-block-id',
  { text: 'First part' },
  'paragraph',
  { text: 'Second part' },
  1
);

blocks.stopBlockMutationWatching(index)

void

Stop mutation watching on a block at the specified index. Use this to prevent spurious block-changed events during block replacement operations.

When to use

A niche method. Call it before you replace a block's DOM programmatically, to suppress spurious block-changed events. The next render re-arms it.

TypeScript
// Replace a block without triggering change events
editor.blocks.stopBlockMutationWatching(0);
// Perform block replacement...
// Mutation observer will not fire for this block

blocks.startBlockMutationWatching(blockId)

void

Re-arms mutation watching on a block previously silenced by stopBlockMutationWatching. It takes an id, not an index, because inserts and replacements between the two calls shift indexes. An id that no longer exists is silently skipped: a block replaced in place was constructed with its own watcher.

TypeScript
const blockId = editor.blocks.getBlockByIndex(0)?.id;
editor.blocks.stopBlockMutationWatching(0);
// Perform block replacement...
if (blockId) {
  editor.blocks.startBlockMutationWatching(blockId);
}

blocks.transact(fn)

void

Groups every block operation performed inside fn into a single undo entry. fn must be SYNCHRONOUS. Operations that land after an await are no longer part of the group. Use it so structural edits are not partially undoable.

TypeScript
editor.blocks.transact(() => {
  editor.blocks.insert('paragraph', { text: 'One' });
  editor.blocks.insert('paragraph', { text: 'Two' });
});
// A single Cmd+Z removes both blocks

blocks.beginTransaction()

void

Opens an undo group that stays open across async boundaries. Every block operation until endTransaction() lands in one undo entry.

Use it for pointer gestures that mutate the document continuously, such as dragging a table's corner to add rows. transact() cannot help there, because it only wraps a synchronous function. Pair every call with endTransaction().

TypeScript
editor.blocks.beginTransaction();
// ... continuous mutations across async boundaries ...
editor.blocks.endTransaction();

blocks.endTransaction()

void

Close the undo group opened by beginTransaction().

TypeScript
editor.blocks.beginTransaction();
// ... continuous mutations across async boundaries ...
editor.blocks.endTransaction();

blocks.transactWithoutCapture(fn)

void

Run block operations without recording anything in the undo history. Use it for auto-repair and normalization (e.g. ensuring an empty cell always has a block) that Cmd+Z should never step through.

Pass { derivedFrom: blockId, from: ['url'] } when the operations are data worked out from those keys of a block, such as a finished upload that replaces the block. They still add no undo step and keep redo, but they join the step that wrote those values: undoing it takes them back, and redo brings them back. An update() started inside fn counts too, although it finishes later. While a save of that block is still in flight, fn runs once it lands, after the call returns.

TypeScript
editor.blocks.transactWithoutCapture(() => {
  editor.blocks.insert('paragraph', {}, undefined, 0);
});
// Nothing was added to the undo history

blocks.setPointerDragActive(active)

void

Tell core that a pointer drag interaction started or ended. While it is active, DOM-mutation-triggered Yjs syncs are suppressed. That way browser DOM churn during the drag cannot corrupt Yjs state.

TypeScript
editor.blocks.setPointerDragActive(true);
// ... run the drag gesture ...
editor.blocks.setPointerDragActive(false);

Properties

PropertyTypeDescription
isSyncingFromYjsboolean

A readonly getter. It is true while a Yjs sync operation (undo/redo) is in progress. Tools read it to skip cleanup that would fight undo state. This is a PROPERTY on editor.blocks, unlike the React hook's isSyncingFromYjs() method.

isPointerDragActiveboolean

A readonly getter. It is true while a pointer drag interaction is active. Framework adapters read it to defer a programmatic dispatchChange in the middle of a drag, because core silently drops such a change. They re-dispatch it once the drag ends.

isApplyingRemoteChangeboolean

A readonly getter. It is true while the editor applies a change another client made. A peer's new block can arrive before the write that gives it a parent. A container tool reads this to tell such a block from one this client adds in its own sync window.