---
title: "Blok OutputData — Saved JSON Format Reference"
description: "The exact shape save() returns: time, version, and the blocks array with id, type, data, tunes, parentId, and contentIds."
source: https://blokeditor.com/docs/output-data/
lastmod: 2026-09-07
---

Framework JavaScript

Data types OutputData

On this page equalsOutputData(a, b, options?)

# OutputData: Blok's saved JSON format

The data structure returned by the save() method. Input positions — the `data` config option, `render()`, `blocks.render()`, and `blocks.insertMany()` — also accept the loose wire variants `LooseOutputData` / `LooseOutputBlockData`, where block `data`, `id`, `parent`, `content`, and `time` may be `null`: a `null` `data` becomes `{}`, a `null`/empty `id` gets a generated one, and a `null` `parent` / `null`-or-empty `content` is treated as absent (root-level, childless). Saved output is always the strict shape.

[Edit this page on GitHub](https://github.com/JackUait/blok/blob/main/docs/src/components/api/api-data.ts)

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

### equalsOutputData(a, b, options?)

boolean

Structural equality for saved documents, exported from the main entry. Compares the `blocks` arrays deeply; the volatile `time` and `version` envelope fields are ignored, so a document round-tripped through save() compares equal to its echo. Block ids participate only when BOTH sides carry one: the editor mints fresh ids for id-less content, so a legacy document (or a backend that strips ids) still compares equal to its saved echo — no id-stripping wrapper needed on the consumer side. Edit metadata (`lastEditedAt` / `lastEditedBy`) never participates either: it records who touched a block and when, not what it says, so a document whose only delta is a stamp counts as unchanged. Nullish documents and loose wire shapes are accepted — `null`/`undefined` compares equal to `{ blocks: [] }`, and a DTO's `parent: null` / `content: null` equals the saved shape that omits them. The third argument is `EqualsOutputDataOptions` (also exported from the main entry): `ignoreEmptyDefaultBlocks` (default `false`) drops empty blocks of the DEFAULT block tool from both sides before comparing, so a pristine editor holding one empty paragraph equals a saved-empty baseline — the flag to use for dirty-vs-baseline checks. Empty NON-default blocks (a content-less divider, an empty image) are kept.

When to use

Use it to gate persistence or state updates on real changes — `time` and `version` differ on every save, so a naive deep-equal always reports a change.

TypeScript

```
import { equalsOutputData } from '@bloklabs/core';

const saved = await editor.save();
if (!equalsOutputData(saved, previousData)) {
  await persist(saved); // only hit the backend on real changes
}
```

### isEmptyOutputData(data)

boolean

True when the document carries no user content, exported from the main entry: it is nullish, has no blocks, or every block's data holds only empty values (blank/whitespace-only strings, empty arrays/objects). Numbers and booleans (`level`, `checked`, styles) are presentation metadata and never count as content on their own.

When to use

Content-less visual blocks (e.g. a divider with `data: {}`) count as empty — check `blocks.length` when mere block presence matters.

TypeScript

```
import { isEmptyOutputData } from '@bloklabs/core';

const data = await editor.save();
submitButton.disabled = isEmptyOutputData(data);
// → true for a fresh editor holding one blank paragraph
```

### normalizeOutputData(data)

OutputData

Normalizes a whole loose backend DTO into the strict saved OutputData shape, exported from the main entry. A nullish document becomes `{ blocks: [] }`; `null` envelope fields (`time`/`version`) are dropped; each block is normalized so `null`/missing `data` becomes `{}`, `null`/empty ids are dropped for regeneration, and nullish/empty hierarchy references (`parent: null`, `content: null`, `content: []`) are dropped as absent. Unlike a hand-written `blocks.map(...)` mapper it preserves every passthrough field — `tunes`, real `parent`/`content` references, `indent`, edit metadata — so hierarchy and tunes are never silently lost. Idempotent: a strict document passes through unchanged.

TypeScript

```
import { normalizeOutputData } from '@bloklabs/core';

// A loose Editor.js-era DTO (data: null, id: null) from your backend
const strict = normalizeOutputData(dtoFromApi);
// → strict OutputData, safe to persist or diff — no blind `as OutputData` cast
```

### normalizeOutputBlocks(blocks)

OutputBlockData[]

Block-level counterpart of normalizeOutputData, exported from the main entry: normalizes an array of loose wire blocks into the strict saved shape (`null`/missing `data` → `{}`, `null`/empty `id` dropped for regeneration, nullish/empty `parent`/`content` dropped as absent) while passing every other field through untouched. Use normalizeOutputData when you hold the whole document envelope.

TypeScript

```
import { normalizeOutputBlocks } from '@bloklabs/core';

const blocks = normalizeOutputBlocks(looseBlocksFromApi);
// → OutputBlockData[] with tunes/parent/content/indent intact
```

### BlokData<T>

{ [K in keyof T]: T[K] }

A type helper, exported from the main entry, that lets an `interface` block-data shape fit the `data` slot. A TS `interface` has no implicit index signature and is therefore not assignable to `Record<string, unknown>`, so `OutputBlockData<'task', TaskData>` fails to compile when `TaskData` is an interface. `BlokData<T>` re-projects `T` through a homomorphic mapped type, which the compiler does treat as having an implicit index signature, while every declared key keeps its precise type. No rewrite is needed: an existing interface value is assignable to `BlokData<T>`, and a `type` alias already satisfies the slot on its own.

TypeScript

```
import type { BlokData, OutputBlockData } from '@bloklabs/core';

interface TaskData { title: string; done: boolean }

const block: OutputBlockData<'task', BlokData<TaskData>> = {
  type: 'task',
  data: { title: 'Ship it', done: false },
};
```

### flattenTree(spec, options?)

Array<OutputBlockData & { id: string }>

Turns an ergonomic nested spec into the flat DFS pre-order `OutputBlockData[]` Blok stores, wiring every `parent`/`content` link for you, exported from the main entry alongside its `BlockTreeSpec`, `BlockRunSpec`, `BlockTreeNode` and `FlattenTreeOptions` types. A spec node is `{ type?, data?, tunes?, id?, children? }`; the pure counterpart of the live `blocks.insertTree()` mutation — the same DFS without an editor — so nested content (columns, tables, a whole document) can be seeded without hand-authoring `parent`/`content` id arrays. Every returned block has a resolved `id` (generated when the spec omitted one), so the array is safe to reference by id; leaves omit the empty `content` array. Content that is already flat — a stored Blok document a migration is splicing into a page — goes in as a **run node**, `{ blocks: [...] }`, at the root or as a child: the run is spliced verbatim (ids, `data`, `tunes` and existing `parent`/`content` links are kept) and only the blocks it left un-parented are re-parented onto the enclosing node, the same rule `blocks.insertMarkdown()` applies to a converted run. Because nothing is re-derived, ids stay stable, so a migration can run in batches without duplicating blocks it already wrote. `options` takes `parentId` (the `parent` assigned to the root node(s)) and `generateId` (id generator for nodes without an explicit `id` — pass a deterministic one for reproducible output). Reusing an explicit `id` within the spec throws, and so does passing a pre-flat block as a tree node (its `parent`/`content` links would be dropped silently).

TypeScript

```
import { flattenTree } from '@bloklabs/core';

// A two-column layout, written as a tree instead of parent/content id arrays
const blocks = flattenTree([
  {
    type: 'column_list',
    children: [
      { type: 'column', children: [{ type: 'paragraph', data: { text: 'Left' } }] },
      { type: 'column', children: [{ type: 'paragraph', data: { text: 'Right' } }] },
    ],
  },
]);

// Ready to hand to the `data` config option, render() or blocks.insertMany()
console.log(blocks); // flat, DFS pre-order, every parent/content link wired

// An already-flat saved document goes in as a run node — spliced verbatim,
// ids kept, only its top-level blocks re-parented under the column
const migrated = flattenTree({
  type: 'column',
  children: [{ blocks: legacyPage.blocks }],
});
```

### isBlockType(block, type)

block is OutputBlockData<K, BlokBlockDataMap[K]>

A type guard exported from `@bloklabs/core/tools` that narrows a saved block to a known block type, so its `data` is typed through the `BlokBlockDataMap` registry instead of `Record<string, unknown>` — it replaces the `block.type === 'header'` check plus `data as HeaderData` cast. `BlokBlockDataMap` maps each built-in block type to its data shape and is exported from the same subpath; it is augmentable, so a custom tool registers its own shape by declaration merging and gets narrowed the same way.

TypeScript

```
import { isBlockType } from '@bloklabs/core/tools';
import type { OutputData } from '@bloklabs/core';

function logHeadings(saved: OutputData) {
  for (const block of saved.blocks) {
    if (isBlockType(block, 'header')) {
      console.log(block.data.level); // number — no cast
    }
  }
}

// A custom tool joins the registry by declaration merging
declare module '@bloklabs/core/tools' {
  interface BlokBlockDataMap {
    'my-widget': { widgetId: string };
  }
}
```

### blocksOfType(data, type)

Array<OutputBlockData<K, BlokBlockDataMap[K]>>

The collection counterpart of `isBlockType`, also exported from `@bloklabs/core/tools`: collects every saved block of a given type from a document with each result's `data` typed through `BlokBlockDataMap`. Null-tolerant — a `null`/`undefined` document, and the loose `LooseOutputData` wire shape, are accepted — so it replaces the `(data?.blocks ?? []).filter(...)` plus cast that every feature re-writes.

TypeScript

```
import { blocksOfType } from '@bloklabs/core/tools';
import type { OutputData } from '@bloklabs/core';

// `saved` may be null — blocksOfType tolerates it and returns []
function buildToc(saved: OutputData | null) {
  return blocksOfType(saved, 'header')
    // data.text / data.level are typed — no cast
    .map((block) => ({ text: block.data.text, level: block.data.level }));
}
```

### EMPTY_OUTPUT_DATA

OutputData

A shared, deeply frozen empty document (`{ blocks: [] }`), exported from the main entry. Use it in place of a hand-written `{ blocks: [] }` literal for cleared/pristine baselines. Frozen (blocks array included) so a shared reference can never be mutated into a stale non-empty baseline.

TypeScript

```
import { EMPTY_OUTPUT_DATA, equalsOutputData } from '@bloklabs/core';

const saved = await editor.save();
const isPristine = equalsOutputData(saved, EMPTY_OUTPUT_DATA, {
  ignoreEmptyDefaultBlocks: true,
});
```

### toRenderableData(data)

OutputData | LooseOutputData

Maps a controlled `data` value to something render()/blocks.render() accepts, exported from the main entry: a whole-document `null` (a controlled "clear to empty") becomes `{ blocks: [] }`; any real document passes through untouched. render()'s strict guard reads `data.blocks` and would throw on `null`, so route a nullable controlled value through this first.

TypeScript

```
import { toRenderableData } from '@bloklabs/core';

// `draft` may be null when the host clears the document
await editor.blocks.render(toRenderableData(draft));
```

### createEmittedEchoWindow(capacity?)

{ record; matches; clear }

Creates a bounded window of recently emitted onSave payloads for recognizing controlled-`data` echoes, exported from the main entry. Deduping against only the LAST emitted payload is not enough: a host that persists on save and refetches can hand back a STALE echo (an earlier save arriving after a newer one already replaced the baseline), and re-rendering it would clobber the caret and any content typed since. Matching is structural (equalsOutputData), so envelopes reshaped in transit (fresh `time`, stripped ids) still count as echoes.

TypeScript

```
import { createEmittedEchoWindow } from '@bloklabs/core';

const echoes = createEmittedEchoWindow();
// in onSave: echoes.record(data)
// before re-rendering incoming props: if (echoes.matches(next)) return;
```

### migrateLegacyBlocks(blocks, options?)

OutputBlockData[]

Migrate legacy / Editor.js-style blocks into Blok's hierarchical flat-with-references format, exported from the `@bloklabs/core/migrate` subpath — the same transform the renderer runs automatically at load. Legacy nested shapes (list items, toggle/callout children) explode into separate blocks linked by `parent`/`content` (`parent` is the saved-document field; `parentId` is the useBlocks BlockNode snapshot field), and id-less blocks are stamped with an id. Already-hierarchical blocks pass through unchanged, so it is safe to run on current data and idempotent across repeated runs. `options` exposes the migration context: `generateId` makes the pass PURE (migrate the same document twice and the outputs are equal — needed to compare a stored doc against its migration, or to re-run migration per render without minting fresh ids), `onLossyField` delivers every dropped field instead of dumping it to `console.warn`, and `rules` adds your own grammar entries. `migrateLegacyOutputData(data, options?)` is the envelope-preserving variant; `needsLegacyMigration(blocks, options?)` reports whether a migration would change anything; `matchLegacyRule(block, options?)` is the per-block primitive that returns the entry claiming a single block (or `null`) without re-scanning the table for every block. Every rules-taking entry point accepts either the options object or a bare `rules` array, so passing the array directly can't silently read as "no rules".

When to use

For a data shape only a specific tool understands (a columns layout, a custom media envelope) that core's built-in migration can't read, give that tool a static `upgradeData(data)` — a pure function returning the tool's current data shape. Blok runs it at load, while composing each stored block, before the tool is constructed; a hook that throws is caught and the block loads with its stored data.

TypeScript

```
import {
  migrateLegacyBlocks,
  migrateLegacyOutputData,
  needsLegacyMigration,
  matchLegacyRule,
} from '@bloklabs/core/migrate';

// Batch-upgrade persisted Editor.js documents
const upgraded = migrateLegacyOutputData(storedDocument);

// Or migrate just the blocks, skipping the pass when already current
const blocks = needsLegacyMigration(stored.blocks)
  ? migrateLegacyBlocks(stored.blocks)
  : stored.blocks;

// Deterministic migration: same input → equal output, every time
let n = 0;
const pure = migrateLegacyBlocks(stored.blocks, {
  generateId: () => `blk-${n++}`,
  onLossyField: ({ blockType, field }) => report(blockType, field),
});

// Dispatch per block without allocating a throwaway array
const entry = matchLegacyRule(stored.blocks[0]); // → { legacyType, targetType, … } | null
```

### migrations (config) & migrateOutputData(data, migrations)

OutputData

Declare per-type "old data shape → new data shape" rules from OUTSIDE the tool class. Where `upgradeData` must live inside a tool you own, `migrations` is a map keyed by block type you pass in editor config — so you can migrate a third-party tool you don't control, or your own tool without editing (and re-shipping) its class. Each rule is a pure `(data) => data` transform (return the input unchanged, or `undefined`, when already current). Blok applies it at load, after the tool's own `upgradeData` and BEFORE format analysis — so `dataModel: 'auto'` sees the post-migration shape and an 'auto' round-trip can't quietly undo the migration by saving the old shape back. A throwing rule falls back to the stored data (never a blank editor). The same map works offline: pass it to `migrateOutputData(data, migrations)` (or `migrateBlocks(blocks, migrations)`) from `@bloklabs/core/migrate` to batch-upgrade persisted records without opening an editor. Available on all three framework adapters as the `migrations` prop/input.

When to use

Rules must be pure and idempotent — they run on every load, including on already-current data. Prefer `migrations` (config) for shapes a host decides from the outside; prefer a tool's own `upgradeData` for shapes only that tool understands. They compose: `upgradeData` runs first, then the config `migrations` rule for that type.

TypeScript

```
// 1. At load, via editor config
new Blok({
  tools: { myCard: MyCard },
  migrations: {
    // key = block type; old shape → new shape
    myCard: (data) => ('name' in data ? { ...data, title: data.name } : data),
    // `data` is BlockToolData (Record<string, unknown>), so narrow before reading
    image: (data) => {
      const file = data.file as { url?: string } | undefined;

      return file?.url ? { ...data, url: file.url } : data;
    },
  },
});

// 2. Offline / batch — same rules, no editor
import { migrateOutputData } from '@bloklabs/core/migrate';

const upgraded = migrateOutputData(storedDocument, {
  myCard: (data) => ({ ...data, title: data.name }),
});
```

### migrate(data, { migrations, rules, generateId, onLossyField })

{ data: OutputData; report: MigrationReport }

The composed entry point: runs BOTH migration passes in the one correct order and reports what the migration cost. Data rules (`migrations`) run first, then grammar rules (`rules`) restructure the tree. That order is load-bearing: data rules are keyed by block TYPE, and the grammar rewrites types (`linkTool` → `bookmark`) and explodes containers into many blocks — so a rule run after the grammar never fires, and the block stays silently unmigrated. Data rules shape the grammar's input; the grammar owns the output shape for the types it rewrites. The `report` names every field the mapping could not carry over (`lossyFields`) and every data rule that threw (`errors`), so a batch upgrade of persisted records is no longer silent about its own data loss.

When to use

`report.lossyFields` is what `console.warn` used to say and nothing could read. Log it next to a batch upgrade and you get an auditable record of exactly what the upgrade dropped, per block type.

TypeScript

```
import { migrate } from '@bloklabs/core/migrate';

let n = 0;
const { data, report } = migrate(storedDocument, {
  // 1. data rules — old data shape → new data shape, by block type
  migrations: {
    myCard: (d) => ('name' in d ? { ...d, title: d.name } : d),
  },
  // 2. grammar rules — structural: type changes, 1:N splits, sibling absorption
  rules: [alertRule],
  generateId: () => `blk-${n++}`,
});

report.lossyFields; // [{ blockType: 'linkTool', field: 'meta.site_name', verb: 'dropped' }]
report.errors;      // [{ type: 'myCard', error }] — that block kept its stored data
```

### rules (custom legacy grammar entries)

LegacyGrammarEntry[]

Teach the migration machinery a legacy shape Blok doesn't know. A grammar entry is `{ legacyType, detect, expand, targetType, cardinality, contributesNesting, lossyFields, docNote }`; passing entries via `rules` reuses the whole interpreter — recursion into container bodies, the orphan re-parenting invariant, 1:N splits, id minting — instead of re-implementing the dispatch loop around a data-only rule. Host entries are matched BEFORE the built-in table, so they can also override a built-in mapping. Unlike a `migrations` rule, an entry may change a block's `type` and emit several blocks. `expand(block, ctx, { siblings, index })` may also return `{ blocks, consumed }` to absorb the following `consumed` siblings — the shape flat-with-count legacy formats need (a container storing its body as "the next N blocks"); `consumed` is clamped to what remains, so a truncated document can't over-consume. Read `LEGACY_GRAMMAR` to introspect the built-in coverage.

When to use

A container rule whose body is stored as a COUNT of following siblings returns `{ blocks, consumed }` — the interpreter skips exactly that many siblings, so the children are re-parented once and never emitted twice.

TypeScript

```
import { migrate, LEGACY_GRAMMAR, type LegacyGrammarEntry } from '@bloklabs/core/migrate';

// A legacy `alert` → callout + child paragraph (type change AND a 1:N split).
// The annotation is load-bearing: without it `cardinality` widens to `string`
// and `detect`/`expand` lose their contextual parameter types.
const alertRule: LegacyGrammarEntry = {
  legacyType: 'alert',
  targetType: 'callout',
  cardinality: '1:N',
  contributesNesting: true,
  lossyFields: [],
  docNote: '`alert` → `callout` + message paragraph.',
  detect: (block) => block.type === 'alert',
  expand: (block, ctx) => {
    const calloutId = block.id ?? ctx.generateId();
    const childId = ctx.generateId();

    return [
      { id: calloutId, type: 'callout', data: { emoji: '🚨' }, content: [childId] },
      { id: childId, type: 'paragraph', data: { text: block.data.message }, parent: calloutId },
    ];
  },
};

const { data } = migrate(storedDocument, { rules: [alertRule] });

// What does Blok migrate out of the box?
LEGACY_GRAMMAR.map((entry) => [entry.legacyType, entry.targetType, entry.lossyFields]);
```

TypeScript

```
// Save editor content
const data = await editor.save();

// Result structure:
interface OutputData {
  version?: string;    // Editor version
  time?: number;       // Save timestamp
  blocks: OutputBlockData[]; // Array of block data
}

// Example output:
{
  "version": "1.13.0",
  "time": 1704067200000,
  "blocks": [
    {
      "id": "p6QK0Xz1Ab",
      "type": "paragraph",
      "data": { "text": "Hello, world!" }
    },
    {
      "id": "hM3lTn9RdC",
      "type": "header",
      "data": { "text": "Title", "level": 2 }
    }
  ]
}
```

## OutputData

| Property | Description |
| --- | --- |
| `version` | `string (optional)` | Editor version |
| `time` | `number (optional)` | Timestamp of save |
| `blocks` | `OutputBlockData[]` | Array of block data |
