Store application data in DOCX
Choose the DOCX structure that matches how your application stores and presents data.
Choose a document structure by what the data means and how other DOCX tools should understand it. Do not recreate the ProseMirror node or mark that previously carried it.
| What you need | Use | Stored as | How it appears |
|---|---|---|---|
| A Word-compatible citation with a bibliography source | doc.citations | A citation field and source record | From the field result |
| A calculated or cached Word field | doc.fields | An OOXML field instruction and result | From the field result |
| A structured region that people can edit | doc.contentControls | A content control (w:sdt) | Yes |
| Application JSON attached to a text range | doc.metadata | A hidden inline content control plus Custom XML | Not visible by itself |
| Application data that is not attached to text | doc.customXml | A Custom XML part | Not visible |
Use the public API rather than editing these XML elements directly. SuperDoc keeps the related package parts and relationships consistent when the document changes.
Anchored application data
Use anchored metadata for an application-owned record such as a review finding, external reference, or backend identifier. The caller chooses the record ID and namespace:
// A user action, not module scope: the guards below return early, and `return`
// is only legal inside a function.
async function attachFinding() {
const doc = superdoc.activeEditor?.doc;
// `capture()` reports readiness; `current()` does not. A worker-backed read is
// `pending` or `stale` while it settles and still carries the previous range,
// so writing from it anchors the record to the user's earlier selection.
//
// Read `target`, not `selectionTarget`: `target` is always a `TextTarget`
// (one segment per paragraph the selection touches), which is what lets a
// selection spanning multiple paragraphs attach as one record.
// `selectionTarget` collapses a multi-paragraph selection to its first/last
// endpoints and cannot express that.
const capture = superdoc.ui.selection.capture();
const target = capture?.status === 'ready' ? capture.target : null;
// Anchoring is one or more non-empty text ranges — one segment per
// paragraph — in the main document **body**. Check every part of that
// before writing, rather than handling rejections:
//
// - Every segment must be non-empty.
// - `story` is per-target: a non-body target (header, footer, footnote,
// endnote, textbox) is rejected outright, since the adapter resolves
// paragraphs against the main document part only.
// - A selection crossing deleted tracked text reports `coordinateSpace:
// 'tracked'`, and the anchor rewrite searches visible `w:t` runs only, so
// the same offsets address different characters.
//
// These guards are necessary but not sufficient. A selection crossing a
// table-cell or section boundary still throws, and nothing in the public
// selection shape reports that ahead of time, so the catch below is the
// only place that case can be handled.
if (!doc || !target) return;
if (target.coordinateSpace === 'tracked') return;
if (target.segments.length === 0) return;
if (target.segments.some((segment) => segment.range.start === segment.range.end)) return;
if (target.story !== undefined && target.story.storyType !== 'body') return;
// `attach()` throws for several ordinary cases instead of returning an
// unsuccessful receipt: when the id already exists (including the second time
// this action runs), when segments are not contiguous paragraphs (or cross a
// table-cell/section boundary), when a segment overlaps a content control,
// a hyperlink, or a simple field (`w:fldSimple`, code and cached result
// alike — a complex field's cached result text, outside any wrapper, is
// the one field-adjacent case that IS attachable), and when a paragraph is
// not in the main document part.
//
// Use try/catch rather than `.catch()`. This facade is typed `MaybePromise`, so
// on a synchronous host `attach()` returns the receipt itself and has no
// `.catch` method, and a synchronous validation throw happens before any
// promise exists to reject.
try {
const receipt = await doc.metadata.attach({
id: 'finding-42',
namespace: 'urn:example:review',
target,
payload: { sourceId: 'source-7', justification: 'Verify this statement' },
});
if (!receipt.success) reportToUser(receipt.failure.message);
} catch (error) {
reportToUser(error instanceof Error ? error.message : String(error));
}
}Metadata persists with the DOCX but does not add visible styling. Add a render-only extension visual when people need to see the anchored range.
The anchor is painted as a content control — one per paragraph it spans, all sharing the same tag when the anchor covers multiple paragraphs. A viewport hit carries the metadata ID in tag, while id identifies the underlying content control. A record with that ID existing is not proof the click landed on its anchor: nothing stops an ordinary content control from carrying a tag that matches a record anchored elsewhere. Pass the hit's physical control ID to metadata.resolve and metadata.get so a foreign body control does not acquire that record's payload. Reject non-body hits before either lookup:
import type { SelectionTarget, TextTarget } from 'superdoc/ui';
// Both endpoints must be `text` to compare positions at all: a `nodeEdge`
// endpoint carries a node reference rather than a block and offset.
//
// Compare the whole story, not just its `storyType`. Two different headers are
// both `headerFooterPart`, and block ids are only unique within a story, so
// comparing the type alone lets an anchor in one header match a control in
// another. The Document API has an internal canonical key for this; until it is
// public, discriminate on the fields that identify each variant.
const storyKey = (story: SelectionTarget['story']) => {
if (!story) return 'body';
switch (story.storyType) {
case 'headerFooterPart':
return `headerFooterPart:${story.refId}`;
case 'headerFooterSlot':
return `headerFooterSlot:${story.section.sectionId}:${story.headerFooterKind}:${story.variant}`;
case 'footnote':
case 'endnote':
return `${story.storyType}:${story.noteId}`;
case 'textbox':
return `textbox:${story.textboxId}`;
default:
return 'body';
}
};
// `anchor.target` is a plain `SelectionTarget` for a single-paragraph anchor
// (unchanged since v1), or a `TextTarget` for one spanning multiple
// paragraphs — one control per segment, all sharing the clicked tag. A single
// clicked control is always confined to one paragraph, so match its range
// against ANY ONE of the anchor's segments rather than the whole anchor's
// start-to-end span.
const controlMatchesAnchor = (anchor: SelectionTarget | TextTarget, control: SelectionTarget) => {
if (control.start.kind !== 'text' || control.end.kind !== 'text') return false;
if (control.start.blockId !== control.end.blockId) return false;
if (storyKey(anchor.story) !== storyKey(control.story)) return false;
const controlBlockId = control.start.blockId;
const controlStart = control.start.offset;
const controlEnd = control.end.offset;
if (anchor.kind === 'text') {
return anchor.segments.some(
(segment) =>
segment.blockId === controlBlockId && segment.range.start === controlStart && segment.range.end === controlEnd,
);
}
if (anchor.start.kind !== 'text' || anchor.end.kind !== 'text') return false;
return (
anchor.start.blockId === controlBlockId && anchor.start.offset === controlStart && anchor.end.offset === controlEnd
);
};
editorShell.addEventListener('pointerdown', async (event) => {
const doc = superdoc.activeEditor?.doc;
if (!doc) return;
const context = superdoc.ui.viewport.contextAt({ x: event.clientX, y: event.clientY });
// Hits are innermost-first, and a metadata anchor can contain an ordinary
// nested control. Test every content-control hit rather than the innermost,
// or a nested control shadows the anchor that encloses it.
//
// Metadata anchors live only in the body. Painted content-control hits carry
// a story locator for non-body controls; reject those before either body-only
// metadata lookup, even when an id and tag collide with a body anchor.
for (const hit of context.entities.filter((entity) => entity.type === 'contentControl')) {
if (!hit.tag) continue;
const paintedStory = hit.story;
if (
paintedStory !== undefined &&
(paintedStory === null ||
typeof paintedStory !== 'object' ||
!('kind' in paintedStory) ||
paintedStory.kind !== 'story' ||
!('storyType' in paintedStory) ||
paintedStory.storyType !== 'body')
)
continue;
const story = paintedStory === undefined ? undefined : ({ kind: 'story', storyType: 'body' } as const);
// A metadata anchor is a hidden **inline** content control, so `kind` below
// must say `inline`. Read it from the hit rather than hardcoding it: `scope`
// is absent when the painted control carries no scope attribute, and a
// block control cannot be an anchor, so both cases skip.
if (hit.scope !== 'inline') continue;
// Read the control through the Document API rather than the UI catalog.
// `ui.contentControls.list()` schedules its read and returns whatever is
// cached, so an early click sees an empty or stale array; the awaited
// Document API call always answers about the current document.
//
// Compare the whole range, not just the block: a colliding tag can sit in
// the same paragraph as the real anchor.
//
// `get()` throws rather than returning null for an id it cannot find, and
// an uncaught throw here would surface as an unhandled rejection from this
// listener. Treat a failed lookup as "not this hit" and keep scanning.
let control: Awaited<ReturnType<typeof doc.contentControls.get>> | null = null;
let anchor: Awaited<ReturnType<typeof doc.metadata.resolve>> | null = null;
try {
[control, anchor] = await Promise.all([
doc.contentControls.get({
target: { kind: 'inline', nodeType: 'sdt', nodeId: hit.id, ...(story ? { story } : {}) },
}),
doc.metadata.resolve({
id: hit.tag,
contentControlId: hit.id,
story: story ?? { kind: 'story', storyType: 'body' },
}),
]);
} catch {
continue;
}
if (!anchor || !control?.selectionTarget) continue;
if (!controlMatchesAnchor(anchor.target, control.selectionTarget)) continue;
const record = await doc.metadata.get({
id: hit.tag,
contentControlId: hit.id,
story: story ?? { kind: 'story', storyType: 'body' },
});
if (record) openApplicationRecord(record);
return;
}
});Documents saved before physical-control binding may contain two or more same-tag controls for a single record (for example, after an Enter split). Their payload remains available through metadata.get({ id }), but metadata.resolve returns null, metadata.list({ resolvedOnly: true }) omits the record, and metadata.remove rejects ambiguous anchors rather than guessing which controls belong to it. To migrate a trusted document, inspect its DOCX package outside the metadata API: identify the record's <ref id="..."> in its namespaced customXml/item*.xml, verify every intended body <w:sdt> and its own <w:id w:val="..."/> (including absence of duplicate IDs and unrelated same-tag controls), then set that ref's sdAnchorIdsV1="id1,id2" attribute to those physical IDs in document order. Reimport and check metadata.resolve({ id }); if the ownership cannot be verified, do not bind or remove the controls automatically.
Anchored metadata requires one or more non-empty text ranges, in visible coordinates, anchored in the main document body. A single-paragraph target is a SelectionTarget, unchanged since v1. A target spanning multiple paragraphs is a TextTarget with one segment per paragraph: the segments must be contiguous paragraphs in document order, and none of the boundary they cross may be a table cell, a block-level content control, or a section — a target that crosses any of those is rejected. Within a paragraph, a segment may freely cross bold, italic, colour, or character-style boundaries — the anchor preserves each run's own formatting rather than requiring the whole segment to sit in one run. A segment that overlaps a hyperlink or a w:fldSimple field is rejected. A w:fldSimple is rejected in full, code and cached result alike; a complex field (w:fldChar/w:instrText) is different — only its instruction-code runs are rejected, and its cached result text is ordinary anchorable content, same as any other run. The adapter resolves every paragraph against the main document part, so a header, footer, or note paragraph is not found, and the anchor rewrite searches visible w:t runs, so a selection crossing deleted tracked text addresses different characters than its offsets suggest. Resolve the record again with doc.metadata.resolve({ id }) after document edits instead of keeping an old range — it returns the same SelectionTarget | TextTarget union, narrowing to TextTarget only when the anchor spans more than one paragraph.
In V2, metadata.attach also supports a single-paragraph range strictly inside one unlocked, editable inline rich-text content control, or one level inside an existing metadata anchor backed by a single physical control. Both boundaries must be inside the parent. The parent retains its identity and surrounding text, and metadata records keep independent IDs, payloads, and targets. Removing an outer metadata record leaves its inner record at the same target as a top-level anchor. The metadata parent becomes a rich-text control when it acquires a child.
Same-start, same-end, identical, crossing, enclosing, and deeper ranges are rejected atomically. Locked, plain-text, data-bound, or unsupported native parents and multi-segment metadata parents are not eligible. A contained selection that touches another child control, a field (including cached complex-field results), an equation, a drawing, or a tracked wrapper is also rejected. These containment rules do not provide general overlap support.
The metadata read path remains body-only. Painted content-control hits in ui.viewport.contextAt(...).entities report a story locator for non-body controls; body hits omit it. Reject non-body hits before reading metadata, because an unscoped body lookup by a header, footer, note, or textbox control's colliding id and tag can return the body record. context.position is always null in v2 and is not a substitute for the hit's story. The story property is typed unknown, so validate it before passing it to a Document API target.
Anything in a paragraph that is not literal text shifts the two coordinate systems apart. A selection counts a tab, a line break, and an inline image as caret positions, while the anchor rewrite accumulates offsets from w:t contents alone. Any of them before or inside the selection makes the stored anchor land later in the paragraph than the user selected, and a long enough run of following text absorbs the difference so the write succeeds instead of failing.
Tabs make this ordinary rather than exotic: a hanging-indent list item begins with one. There is no public option to reconcile the two coordinate systems today, so prefer selections in paragraphs with no tabs, breaks, or inline objects before the range, and verify anchors in exported files when a paragraph has them.
Citation or application record?
Use doc.citations when Word and other DOCX tools should recognize the value as a citation backed by a bibliography source. Use doc.metadata when the meaning belongs to your application and the document only needs to preserve its ID, payload, and text anchor.
These models are not interchangeable. Check the generated operation reference for the current insertion and presentation capabilities before migrating a citation workflow.
HTML and Markdown projection maps serve a different kind of citation: they map a UTF-16 range in serialized output back
to a public tracked-coordinate TextTarget. They do not create a Word bibliography citation. Keep the map with its
evaluatedRevision, and reproject after a mutation
before using the target for a comment, link, or application record.