Custom nodes reference

Custom node API: every option, every error code, the on-disk format.

The API surface of custom nodes. Start with the guide if you have not built one yet.

A node stores what it knows in two places:

LocationHoldsLimit
w:tagThe node's identity64 characters, including the prefix and name
The payloadEverything else262,144 UTF-16 code units, about 256 KiB for ASCII

The payload is a customXml data part that the control binds to through w:dataBinding. Word preserves both.

defineCustomNode

Identity

ParameterTypeDescription
namestringNode type name. The second segment of the tag.
tagPrefixstringThe prefix this definition claims. docx claims docx:*.

Payload and text

ParameterTypeDescription
schemazod (or any Standard Schema)Shape of the payload. Nothing is bundled; bring your own. A schema that validates asynchronously is refused.
text(data) => stringWhat the document shows, from the payload. Reads the schema's output type.

Chrome

ParameterTypeDescription
labelstringDisplay name for chrome. Defaults to name.
chrome.colorstringChip tint and border.
reviewCard({ attrs, text, data }) => { title, detail } | nullContributes a sidebar card per node.
onClick / onHover(node: ActivatedCustomNode) => voidPointer events on the painted chip.
onEdit(node: ActivatedCustomNode) => voidThe context menu's "Edit {label}" row.

Export and interoperability

ParameterTypeWhen
preserveOnExportboolean | 'text'Controls whether export keeps, unwraps or removes the node. See preserveOnExport.
tagAttrs(data) => Record<string, string>A reader without the payload store should still identify the node.
payloadNamespacestringYou need a specific customXml namespace. Defaults to one from tagPrefix. It goes into an XPath prefix declaration, so quotes, angle brackets and ampersands are rejected.
fromDocx({ attrs, text, data }) => attrs | nullThe node has no schema and keeps its data in the tag, or you need to disown a control by returning null.

The returned CustomNode carries dataOf alongside the definition.

customNodesModule

customNodesModule({ nodes: [Citation], onDiagnostic });
OptionTypeDescription
nodesreadonly AnyCustomNodeDefinition[]The definitions this editor recognizes.
onDiagnostic({ code, name, nodeId, issues }) => void'payload-invalid' or 'payload-missing' on a node it read.
licenseKeystringNever validated at construction, never touches the network.

The listener belongs to the editor the module is registered on. Two editors on one page hear only their own documents, and a listener goes when its editor does.

Writes

FunctionBehavior
insertCustomNode(editor, def, input)Inserts at the caret, or at input.at.
updateCustomNode(editor, def, nodeId, update)Rewrites in place.
removeCustomNode(editor, nodeId)Deletes the node and its payload.

Each is one transaction and one undo step, payload included.

Input

FieldTypeDescription
dataThe schema's input typeThe payload. Validated before anything is written.
attrsRecord<string, string>Tag attrs. Derived by tagAttrs when declared. Passing it overrides the derivation.
textstringDocument text. Derived by text when declared. Passing it overrides the derivation.
at{ paragraphId, offset }Insert position. Defaults to the caret. insertCustomNode only.
lockfalse | 'sdtLocked' | 'sdtContentLocked' | 'contentLocked'Defaults to contentLocked.
aliasstringw:alias, the title Word shows on the control.

updateCustomNode keeps the payload you do not mention. Pass data: null to remove one.

contentLocked prevents inline text editing while leaving the node deletable as a unit, in the editor and in Word. A node carrying a payload is uneditable regardless of lock: the engine refuses content edits inside a bound control, and so does Word.

Returns

{ ok: true, changed: true, nodeId }, or a refusal.

nodeId names the control the write authored. A rewrite replaces the control rather than editing it, so the id passed to updateCustomNode names nothing afterwards. removeCustomNode authors no control, so it returns no nodeId.

A refusal carries a code:

codeMeaning
invalidArgsFixable by passing something else: a payload past the cap, a tag over 64 characters, an offset outside the paragraph.
unsupportedA fact about the document: a lock, a protected form, viewing mode.
notFoundNo document is mounted, or updateCustomNode was given an id no node has.

A payload the schema rejected also carries issues:

FieldTypeDescription
messagestringWhat the schema said.
pathreadonly (string | number)[]Route to the field: ['authors', 0].
pointerstringThe same path joined: authors.0.

Reads

customNodesOf

customNodesOf(editor, options?) reads the body and recognizes every definition registered on the editor, in document order. Pass { nodes } to narrow it. It derives from the document each time it is called. There is no change event, so re-read after an edit rather than holding the array.

A payload with no schema comes back as parsed JSON on a null-prototype object, so hasOwnProperty and instanceof Object do not hold on it.

dataOf

Citation.dataOf(node) returns the schema's output type, or undefined for a different definition's node, one with no payload, or one whose payload the schema rejects. A name is checked when the object has one and never required, so it also works on your own state:

const survey = Citation.dataOf(popoverState); // { data } is enough

Inside text, reviewCard and fromDocx, data is already the schema's output type. Everywhere else it is unknown, because those surfaces carry every definition's nodes under one type.

A definition does not need reviewCard to be read. A node without one is still recognized and still carries its payload; it contributes nothing to the sidebar.

Review items

Nodes with ranges, for anchoring UI. Requires the review module:

const cards = editor.getReviewItems().filter((entry) => entry.item.kind === 'custom');

Export

DestinationCallCustom nodes
Internal storageeditor.save()Kept intact.
External copysaveForExport(editor)Each definition's preserveOnExport decides.

saveForExport calls editor.save(), then applies every definition registered in modules. options.nodes narrows the definitions and leaves all others untouched. The result reports how many controls each policy changed in unwrapped and removed. A refusal returns a reason and no bytes.

destination defaults to 'external'. Set it to 'internal' to return the saved bytes unchanged when one code path handles both destinations:

const copy = await saveForExport(editor, {
  destination: keepOurMarkup ? 'internal' : 'external',
});

preserveOnExport

preserveOnExport applies per definition. editor.save() always keeps nodes intact.

ValueExternal export result
true (default)Keeps the control, tag, binding, payload and text.
'text'Keeps the text and removes the control, binding and associated payload.
falseRemoves the control and its content, including the associated payload.

Use 'text' when recipients need the visible value without your custom-node markup:

const Citation = defineCustomNode({
  name: 'citation',
  tagPrefix: 'docx',
  preserveOnExport: 'text',
});

One document can use all three settings. Export applies each definition independently and never touches a tag that no supplied definition claims.

A node that leaves takes its payload with it, even when other nodes in the same store remain. When the last node for a namespace is gone, both customXml parts, both relationships and the content-type override are removed.

Every story is covered, so a chip in a header is treated like a chip in the body.

Store the saved bytes, not the exported bytes. Exported text does not become a node again.

This removes markup written by this library. It does not touch docProps/app.xml, docProps/core.xml, comment and revision authors, rsids, or custom document properties. It does not anonymize a document.

prepareForExport

saveForExport needs an editor. Where there is none, prepareForExport is the same pipeline over bytes, with the definitions spelled out. It touches no DOM:

import { prepareForExport } from '@docx-editor.dev/pro';

// A document your backend generated with customNodeXml, stripped before it is sent.
const generated = await renderContract(order);
const outgoing = prepareForExport(generated, [Clause, InternalNote]);
if (!outgoing.ok) throw new Error(outgoing.reason);
await email.attach(outgoing.bytes);

List every definition whose nodes might be in the document. A definition you do not pass is not touched, and an untouched node travels whole.

customNodeXml and prepareForExport run without DOM globals, so a backend can author, store and strip custom nodes in Node.

Server-side authoring

customNodeXml(definition, attrs, text, options) builds the same content control as XML, with no editor and no DOM. A node written on a server is recognized identically when the document opens in the editor.

import { customNodeXml } from '@docx-editor.dev/pro';

const built = customNodeXml(Citation, { sourceId: 'smith-2024' }, '(Smith 2024)', {
  data: { sourceId: 'smith-2024', locator: 'p.14', authors: ['Smith, J.'], year: 2024 },
});
if (built.ok) {
  template.replace('{{citation}}', built.xml);
}

For a payload-bearing node, built.store returns both customXml parts, both relationships and the content-type override. Add every item to the package. Word offers to repair a document when a control's binding names a missing store.

encodeCustomNodeTag and decodeCustomNodeTag are the tag codec if you need to read or write the identity yourself. Tags are capped at MAX_TAG_LENGTH.

Nodes without a payload

A node can skip schema and keep everything in the w:tag, which Word caps at 64 characters. Then attrs is all it has, and fromDocx is where you clamp those untrusted strings or return null to leave the control literal:

const Mention = defineCustomNode({
  name: 'mention',
  tagPrefix: 'docx',
  fromDocx: ({ attrs }) => (attrs['userId'] ? attrs : null),
});

Writes then pass attrs and text themselves, since there is no payload to derive from.

attrs and text reaching fromDocx come from the .docx, which the sender controls end to end. They are rendered as text, never as markup; do not build URLs or DOM from them without sanitizing. data has been validated against schema.

Word round-trip

A recognized node is an ordinary inline content control. Word renders its text, honors the lock, and preserves the tag, the binding and the payload. A document edited in Word and reopened here is recognized from the same tag. If a Word user edited the text of an unbound node despite the lock, fromDocx sees the drift.

A document opened without your definition registered renders the control's content literally, as Word does. Nothing is lost.

A bound control is read-only in Word: Word renders its text from the payload and does not accept typing into it.

Payload lifecycle

EventResult
removeCustomNodeThe payload is removed in the same transaction.
A control deleted in WordCollected on the next open, by reconciling against what the document binds.
updateCustomNode with dataLabel and payload are written together.
updateCustomNode without dataThe payload is carried forward under the new label.
A chip cut or copiedThe clipboard carries the chip's text, not the control. Pasting inserts plain text.

The open-time sweep is not undoable: it collects payloads whose control was already gone when the document arrived.

On-disk format

customXml/item1.xml:

<docxEditor xmlns="urn:docx-editor.dev:custom-node:docx">
  <node id="cx1">
    <label>(Smith 2024)</label>
    <data>{"sourceId":"smith-2024","locator":"p.14","authors":["Smith, J."],"year":2024}</data>
  </node>
</docxEditor>

The control in word/document.xml that binds it:

<w:dataBinding w:prefixMappings="xmlns:ns0='urn:docx-editor.dev:custom-node:docx'"
               w:xpath="/ns0:docxEditor/ns0:node[@id='cx1']/ns0:label"
               w:storeItemID="{...}"/>

w:storeItemID matches ds:itemID in customXml/itemProps1.xml. The store is reached through the customXml relationship on the story part; that pair is what picks one store out of several.

Node ids are cx1, cx2, … derived from the store's current contents, so writing the same document twice produces the same bytes. One store per payloadNamespace: two definitions sharing a tagPrefix share a store.

Payloads are capped at 262,144 UTF-16 code units (MAX_CUSTOM_NODE_DATA_LENGTH) on write and on read; labels at 4096 on write. Keys named __proto__, constructor and prototype are stripped from a parsed payload at every depth. A legitimate field with one of those names therefore arrives missing, and a schema requiring it fails with "expected string, received undefined".

Limits

  • No in-place edit dialog. Re-authoring is updateCustomNode with a form you supply. The activation carries nodeId, text and data to prefill it.
  • A bound node cannot be edited in Word. Making one editable requires a two-way binding, which is not implemented.
  • The clipboard carries text only. Cutting a chip and pasting it produces plain text, not a node.

On this page