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.

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. For nodes created at runtime, it falls back to sdBlockId, which is volatile.
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('./contract.docx');
const info = 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