Skip to main content
These are the recommended patterns for new integrations.

Plan with query.match, then apply with mutations

This is the recommended default for most apps: match first, preview, then apply.
const match = editor.doc.query.match({
  select: { type: 'text', pattern: 'foo' },
  require: 'first',
});

const ref = match.items?.[0]?.handle?.ref;
if (!ref) return;

const plan = {
  expectedRevision: match.evaluatedRevision,
  atomic: true,
  changeMode: 'direct',
  steps: [
    {
      id: 'replace-foo',
      op: 'text.rewrite',
      where: { by: 'ref', ref },
      args: { replacement: { text: 'bar' } },
    },
  ],
};

const preview = editor.doc.mutations.preview(plan);
if (preview.valid) {
  editor.doc.mutations.apply(plan);
}

Run multiple edits as one plan

When several changes should stay together, group them into one plan:
const match = editor.doc.query.match({
  select: { type: 'text', pattern: 'payment terms' },
  require: 'first',
});

const ref = match.items?.[0]?.handle?.ref;
if (!ref) return;

const plan = {
  expectedRevision: match.evaluatedRevision,
  atomic: true,
  changeMode: 'direct',
  steps: [
    {
      id: 'rewrite-terms',
      op: 'text.rewrite',
      where: { by: 'ref', ref },
      args: {
        replacement: { text: 'updated payment terms' },
      },
    },
    {
      id: 'format-terms',
      op: 'format.apply',
      where: { by: 'ref', ref },
      args: {
        inline: { bold: 'on' },
      },
    },
  ],
};

const preview = editor.doc.mutations.preview(plan);
if (preview.valid) {
  editor.doc.mutations.apply(plan);
}

Quick search and single edit

For lightweight text edits, use query.match and apply against the canonical selection target returned by the match:
const match = editor.doc.query.match({
  select: { type: 'text', pattern: 'foo' },
  require: 'first',
});

const target = match.items?.[0]?.target;
if (target) {
  editor.doc.replace({
    target,
    text: 'bar',
  });
}

Find text and insert at position

Search for a heading (or any text) and insert a new paragraph relative to it:
// 1. Find the heading by text content
const match = editor.doc.query.match({
  select: { type: 'text', pattern: 'Materials and methods' },
  require: 'first',
});

const address = match.items?.[0]?.address;
if (!address) return;

// 2. Insert a paragraph after the heading
editor.doc.create.paragraph({
  at: { kind: 'after', target: address },
  text: 'New section content goes here.',
});
The address from query.match is a BlockNodeAddress that works directly with create.paragraph, create.heading, and create.table. Use kind: 'before' to insert before the matched node instead. To insert as a tracked change, pass changeMode: 'tracked':
editor.doc.create.paragraph(
  { at: { kind: 'after', target: address }, text: 'Suggested addition.' },
  { changeMode: 'tracked' },
);
Use query.match (not find) for this workflow. query.match returns BlockNodeAddress objects that are directly compatible with mutation targets.
For direct single-operation calls, prefer item.target. For plans or multi-step edits, prefer item.handle.ref so every step reuses the same resolved match.

Chain table mutations with returned refs

For non-destructive table-targeted mutations, reuse result.table.nodeId from the previous success result. You do not need an intermediate find() between calls.
const created = editor.doc.create.table({
  rows: 2,
  columns: 2,
});

if (!created.success || !created.table) return;

const bordered = editor.doc.tables.setBorder({
  nodeId: created.table.nodeId,
  edge: 'top',
  lineStyle: 'single',
  lineWeightPt: 1,
  color: '000000',
});

if (!bordered.success || !bordered.table) return;

const inserted = editor.doc.tables.insertColumn({
  tableNodeId: bordered.table.nodeId,
  columnIndex: 0,
  position: 'right',
});

if (!inserted.success || !inserted.table) return;

editor.doc.tables.setCellSpacing({
  nodeId: inserted.table.nodeId,
  spacingPt: 2,
});
This handoff contract applies to table-targeted calls. Cell-targeted tables.setBorder, tables.clearBorder, tables.setShading, and tables.clearShading still return the targeted tableCell address today.

Build a selection explicitly with ranges.resolve

Use ranges.resolve when you already know the anchor points and want a transparent SelectionTarget plus a reusable mutation-ready ref:
const resolved = editor.doc.ranges.resolve({
  start: {
    kind: 'point',
    point: { kind: 'text', blockId: 'p1', offset: 0 },
  },
  end: {
    kind: 'point',
    point: { kind: 'text', blockId: 'p2', offset: 12 },
  },
});

editor.doc.delete({ target: resolved.target });

if (resolved.handle.ref) {
  editor.doc.format.apply({
    ref: resolved.handle.ref,
    inline: { bold: 'on' },
  });
}

Tracked-mode insert

Insert text as a tracked change so reviewers can accept or reject it:
const receipt = editor.doc.insert(
  { value: 'new content' },
  { changeMode: 'tracked' },
);
The receipt includes a resolution with the resolved insertion point and inserted entries with tracked-change IDs.

Check capabilities before acting

Use capabilities() to branch on what the editor supports:
const caps = editor.doc.capabilities();
const target = {
  kind: 'selection',
  start: { kind: 'text', blockId: 'p1', offset: 0 },
  end: { kind: 'text', blockId: 'p1', offset: 3 },
};

if (caps.operations['format.apply'].available) {
  editor.doc.format.apply({
    target,
    inline: { bold: 'on' },
  });
}

if (caps.global.trackChanges.enabled) {
  editor.doc.insert({ value: 'tracked' }, { changeMode: 'tracked' });
}

Cross-session block addressing

When you load a DOCX, close the editor, and load the same file again, sdBlockId values change — they’re regenerated on every open. For cross-session block targeting, use query.match addresses (NodeAddress with kind: 'block'), which carry DOCX-native paraId-derived IDs when available. This pattern is common in headless pipelines: extract block references in one session, then apply edits in another.
import { Editor } from 'superdoc/super-editor';
import { readFile, writeFile } from 'node:fs/promises';

const docx = await readFile('./contract.docx');

// Session 1: extract block addresses
const editor1 = await Editor.open(docx);
const result = editor1.doc.query.match({
  select: { type: 'node', nodeType: 'paragraph' },
  require: 'any',
});

// Save addresses — for DOCX-imported blocks, nodeId uses paraId when available
const addresses = result.items.map((item) => ({
  address: item.address,
}));
await writeFile('./blocks.json', JSON.stringify(addresses));
editor1.destroy();

// Session 2: load the same file again and apply edits
const editor2 = await Editor.open(docx);
const saved = JSON.parse(await readFile('./blocks.json', 'utf-8'));

// Addresses from session 1 usually resolve when reloading the same unchanged DOCX
for (const { address } of saved) {
  const node = editor2.doc.getNode(address); // works across sessions
}
editor2.destroy();
nodeId stability depends on the ID source. For DOCX-imported content, nodeId comes from paraId when available and is best-effort stable across loads. Runtime-created content is still not guaranteed stable across loads; many nodes use session-scoped editor identity, while some structures such as tables or table cells may expose deterministic fallback IDs instead of raw sdBlockId.
No ID is guaranteed to survive all Microsoft Word round-trips. Re-extract addresses after major external edits or transformations, since Word (or other tools) may rewrite paragraph IDs and SuperDoc may rewrite duplicate IDs on import.

Read document counts

doc.info() returns a snapshot of current document statistics including word, character, paragraph, heading, table, image, comment, tracked-change, SDT-field, and list counts.
const info = editor.doc.info();

console.log(info.counts.words);      // whitespace-delimited word count
console.log(info.counts.characters); // full text projection length (with spaces)
console.log(info.counts.paragraphs); // excludes headings and list items
console.log(info.counts.headings);   // style-based heading detection
console.log(info.counts.tables);     // top-level table containers
console.log(info.counts.images);     // block + inline images
console.log(info.counts.comments);   // unique anchored comment IDs
console.log(info.counts.trackedChanges); // grouped tracked-change entities
console.log(info.counts.sdtFields);      // field-like SDT/content-control nodes
console.log(info.counts.lists);          // unique list sequences
All counts reflect the current editor state, not OOXML metadata. They update naturally as the document changes.

Build a live counter in the browser

doc.info() is a snapshot read. To build a live counter, subscribe to document-change events and refresh counts in the handler — do not poll in a render loop. SuperEditor (raw editor):
editor.on('update', ({ editor }) => {
  const { counts } = editor.doc.info();
  updateDocumentStatsUI({
    words: counts.words,
    characters: counts.characters,
    trackedChanges: counts.trackedChanges,
    sdtFields: counts.sdtFields,
    lists: counts.lists,
  });
});
SuperDoc (wrapper):
superdoc.on('editor-update', ({ editor }) => {
  const { counts } = editor.doc.info();
  updateDocumentStatsUI({
    words: counts.words,
    characters: counts.characters,
    trackedChanges: counts.trackedChanges,
    sdtFields: counts.sdtFields,
    lists: counts.lists,
  });
});

SDK usage

The SDKs do not expose browser event subscriptions. Call doc.info() at workflow boundaries — after opening a document, after a batch of mutations, or before saving.
const doc = await client.open({ doc: './contract.docx' });
const info = await doc.info();
console.log(
  `${info.counts.words} words, ${info.counts.characters} characters, ${info.counts.trackedChanges} tracked changes`,
);

Dry-run preview

Pass dryRun: true to validate an operation without applying it:
const preview = editor.doc.insert(
  { target, value: 'hello' },
  { dryRun: true },
);
// preview.success tells you whether the insert would succeed
// preview.resolution shows the resolved target range