Custom nodes
Your own inline node types (citations, mentions, merge fields) as Word content controls.
Use a custom node for an element DOCX has no type for: a citation, an @mention, a merge field, a signature placeholder. Each is stored as an inline Word content control (w:sdt), so Word renders its text and returns the control unchanged.
Requires the custom-nodes module from @docx-editor.dev/pro.
Citation example
import { z } from 'zod';
import { customNodesModule, defineCustomNode, insertCustomNode } from '@docx-editor.dev/pro';
import { CustomNodeChrome } from '@docx-editor.dev/pro/react';
import { DocxEditor, useDocxEditor } from '@docx-editor.dev/react';
const Citation = defineCustomNode({
name: 'citation',
tagPrefix: 'acme',
schema: z.object({
sourceId: z.string().min(1),
author: z.string(),
year: z.number().int(),
}),
text: (data) => `(${data.author} ${String(data.year)})`,
});
// Built once, outside render: modules are read when the editor is constructed.
const MODULES = [customNodesModule({ nodes: [Citation] })];
function CiteButton() {
const editor = useDocxEditor(); // null until the content is mounted
return (
<button
disabled={!editor}
onClick={() => {
const result = insertCustomNode(editor!, Citation, {
data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
});
if (!result.ok) console.warn(result.reason);
}}
>
Cite
</button>
);
}
export function Editor({ bytes }: { bytes: Uint8Array }) {
return (
<DocxEditor.Root document={bytes} modules={MODULES}>
<CiteButton />
<DocxEditor.Viewport>
<DocxEditor.Content />
<CustomNodeChrome onNodeClick={(node) => console.log(Citation.dataOf(node))} />
</DocxEditor.Viewport>
</DocxEditor.Root>
);
}// citation.ts
import { z } from 'zod';
import { customNodesModule, defineCustomNode } from '@docx-editor.dev/pro';
export const Citation = defineCustomNode({
name: 'citation',
tagPrefix: 'acme',
schema: z.object({
sourceId: z.string().min(1),
author: z.string(),
year: z.number().int(),
}),
text: (data) => `(${data.author} ${String(data.year)})`,
});
// Built once, outside setup: modules are read when the editor is constructed.
export const MODULES = [customNodesModule({ nodes: [Citation] })];<!-- CiteButton.vue: useDocxEditor() reads the root's provide, so it has to run
in a descendant of DocxEditorRoot, not in the component that renders it. -->
<script setup lang="ts">
import { insertCustomNode } from '@docx-editor.dev/pro';
import { useDocxEditor } from '@docx-editor.dev/vue';
import { Citation } from './citation';
const editor = useDocxEditor(); // null until the content is mounted
function cite() {
if (!editor.value) return;
const result = insertCustomNode(editor.value, Citation, {
data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
});
if (!result.ok) console.warn(result.reason);
}
</script>
<template>
<button type="button" :disabled="!editor" @click="cite">Cite</button>
</template><!-- Editor.vue -->
<script setup lang="ts">
import { CustomNodeChrome } from '@docx-editor.dev/pro/vue';
import { DocxEditorContent, DocxEditorRoot, DocxEditorViewport } from '@docx-editor.dev/vue';
import { Citation, MODULES } from './citation';
import CiteButton from './CiteButton.vue';
defineProps<{ bytes: Uint8Array }>();
</script>
<template>
<DocxEditorRoot :document="bytes" :modules="MODULES">
<CiteButton />
<DocxEditorViewport>
<DocxEditorContent />
<CustomNodeChrome :on-node-click="(node) => console.log(Citation.dataOf(node))" />
</DocxEditorViewport>
</DocxEditorRoot>
</template>Insert places (Smith 2024) at the caret as a chip. After you save, open the file in Word, and reopen it here, the editor recognizes the chip and its payload again.
Common tasks
Store data on a node
A payload validated by your schema, up to 262,144 UTF-16 code units, kept in a customXml part.
Show a chip and react to clicks
CustomNodeChrome paints recognized nodes and exposes click and hover handlers and the node
rect.
Insert, edit or delete a node
Three functions, one transaction and one undo step each.
Find every node in the document
customNodesOf(editor) in body order, dataOf to get your type back.
Remove custom-node markup before export
saveForExport applies each definition's preserveOnExport.
Write nodes on a server
customNodeXml returns control XML plus the required package metadata.
Look up a parameter or an error code
Every option on defineCustomNode, every refusal code, the on-disk format.
Define a node
const Citation = defineCustomNode({
name: 'citation', // second segment of the tag
tagPrefix: 'acme', // this definition claims acme:*
schema: CitationData, // shape of the payload
text: (data) => `(${data.author} ${String(data.year)})`, // what the document shows
});schema is any Standard Schema: zod, valibot, arktype. The pro package bundles none of them, so install the schema library your project already uses.
- Invalid data is rejected before anything is written to the document.
- Data read back out is returned as your type instead of
unknown.
Payloads also arrive from .docx files someone else wrote, so the schema validates that JSON before your code uses it. Async validation is refused.
text reads the schema's output, so a .default() or a .transform() has already run.
A node can skip schema and keep everything in its tag, which Word caps at 64 characters. See nodes without a payload.
Modules are read once, at construction. Changing the array after construction has no effect; remount the Root.
Everything else is optional: label and chrome.color for the chip, onClick / onHover / onEdit, reviewCard for a sidebar card, preserveOnExport for export behavior. See the reference.
Render the chips
Chips are content-locked by default, so editing runs through the context menu:
Mount both components inside the viewport:
import { CustomNodeChrome, CustomNodeContextMenu } from '@docx-editor.dev/pro/react';
<DocxEditor.Viewport>
<DocxEditor.Content />
<CustomNodeChrome onNodeClick={(node) => openPopover(node)} />
<DocxEditor.ContextMenu>
<CustomNodeContextMenu onEditNode={(node) => openEditForm(node)} />
</DocxEditor.ContextMenu>
</DocxEditor.Viewport>;<script setup lang="ts">
import { CustomNodeChrome, CustomNodeContextMenu } from '@docx-editor.dev/pro/vue';
import type { ActivatedCustomNode } from '@docx-editor.dev/pro';
const openPopover = (node: ActivatedCustomNode) => {
// Open your popover.
};
const openEditForm = (node: ActivatedCustomNode) => {
// Open your edit form.
};
</script>
<template>
<DocxEditor.Viewport>
<DocxEditor.Content />
<CustomNodeChrome :on-node-click="openPopover" />
<DocxEditor.ContextMenu>
<CustomNodeContextMenu :on-edit-node="openEditForm" />
</DocxEditor.ContextMenu>
</DocxEditor.Viewport>
</template>Both entries export the same component names.
An activated node carries name, attrs, tag, data and rect (viewport-relative, for anchoring your own popover), plus nodeId and text when they resolve.
Remove can be refused, by a locked wrapper or a document open for viewing. Pass onRemoveRefused(node, reason) to show the engine's reason; without it, the menu closes.
Insert, update, remove
import { insertCustomNode, removeCustomNode, updateCustomNode } from '@docx-editor.dev/pro';
insertCustomNode(editor, Citation, { data }); // at the caret, or at input.at
updateCustomNode(editor, Citation, nodeId, { data }); // rewrites in place
removeCustomNode(editor, nodeId); // node and payloadAll three writes use one transaction and create one undo step, including payload changes. Successful inserts and updates return { ok: true, changed: true, nodeId }; refusals include a reason and a code.
An update replaces the control and returns its new node ID. The ID passed to updateCustomNode no longer resolves, so update any state that held it:
const result = updateCustomNode(editor, Citation, card.nodeId, { data: next });
if (result.ok && result.nodeId) setCard({ ...card, nodeId: result.nodeId });A payload the schema rejected returns with issues, each pointing at a field:
const result = insertCustomNode(editor, Citation, { data: form });
if (!result.ok) {
for (const issue of result.issues ?? []) {
setFieldError(issue.pointer, issue.message); // 'year', 'authors.0'
}
}attrs, lock, alias, at and the three refusal codes are in the reference.
Read the nodes
import { customNodesOf } from '@docx-editor.dev/pro';
for (const node of customNodesOf(editor)) {
const citation = Citation.dataOf(node);
if (citation) index.add(citation.sourceId); // typed
}customNodesOf returns recognized nodes from the document body in document order. It excludes headers, footers, footnotes and endnotes. Because each call reads the current document and no change event exists, call it again after every edit. Do not retain the returned array.
Chrome and read surfaces carry every definition's nodes under one type, so their data is unknown. dataOf narrows a node to this definition and validates its payload, so you never write a parse at the call site:
<CustomNodeChrome onNodeClick={(node) => open(Citation.dataOf(node))} /><CustomNodeChrome :on-node-click="(node) => open(Citation.dataOf(node))" />Inside text, reviewCard and fromDocx, data is already the schema's output type.
data is undefined when the node carries no payload, when its binding names a store node the document does not hold, or when the payload fails the schema. The last two report through onDiagnostic:
customNodesModule({
nodes: [Citation],
onDiagnostic: ({ code, name, nodeId, issues }) => {
// code: 'payload-invalid' | 'payload-missing'
console.warn(`${name} ${nodeId}: ${code}: ${issues.join(', ')}`);
},
});Save vs export
Use editor.save() for the copy you store. Word and Word Online preserve the node's text, lock, tag, binding and payload.
Use saveForExport when recipients must not receive custom-node markup, such as internal annotations. Generate that external copy separately from the saved document.
import { saveForExport } from '@docx-editor.dev/pro';
// The copy you keep. Reopens here with the chips working.
await storage.put(docId, new Uint8Array(await editor.save()));
// The copy that leaves. Notes removed, citations flattened to their words.
const outgoing = await saveForExport(editor);
if (!outgoing.ok) throw new Error(outgoing.reason);
download(outgoing.bytes);preserveOnExport decides per definition: true (default) keeps the full control, 'text' keeps the words and drops the control, false removes the node and its content. A tag no definition claims is never touched.
Store the saved bytes, not the exported ones. What the export stripped is gone: unwrapped text does not become a node again.
saveForExport needs an editor. On a server, prepareForExport(bytes, definitions) is the same
pipeline over bytes with no DOM. A definition you do not pass is not touched. See
export.
Common configurations
Store a mention without a payload
This definition stores attributes in the tag without a schema or custom XML part. fromDocx recognizes controls with a userId attribute. Validate untrusted attributes before using them in your application:
const Mention = defineCustomNode({
name: 'mention',
tagPrefix: 'acme',
fromDocx: ({ attrs }) => (attrs['userId'] ? attrs : null),
});Remove an internal note on export
Set preserveOnExport: false to keep the node on save and remove it on export:
const InternalNote = defineCustomNode({
name: 'note',
tagPrefix: 'acme',
schema: NoteData,
text: (data) => data.body,
preserveOnExport: false,
});Create a node on a server
customNodeXml builds a control as XML without an editor or DOM. For nodes with payloads, also add the payload parts, relationships, and the properties part's content-type override. The editor recognizes the node when you open the document:
import { customNodeXml } from '@docx-editor.dev/pro';
const built = customNodeXml(Citation, { sourceId: 'smith-2024' }, '(Smith 2024)', {
data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
});
if (built.ok) template.replace('{{citation}}', built.xml);built.store returns two parts, two relationships, and one content-type override. Add all five items or Word offers to repair the file. See server-side authoring.
Add a review sidebar card
reviewCard contributes one card per node, anchored at its range. useReviewItem() supplies the item for the card containing your component:
import { DocxEditorReview, useReviewItem } from '@docx-editor.dev/pro/react';
function CitationActions() {
const item = useReviewItem();
if (item?.kind !== 'custom') return null;
return <button onClick={() => openSource(item)}>Open source</button>;
}
<DocxEditorReview>
<CitationActions />
</DocxEditorReview>;<!-- CitationActions.vue -->
<script setup lang="ts">
import { useReviewItem } from '@docx-editor.dev/pro/vue';
const item = useReviewItem();
</script>
<template>
<button v-if="item?.kind === 'custom'" type="button" @click="openSource(item)">
Open source
</button>
</template>This needs the review module registered alongside the custom-nodes module.
Next steps
- Custom nodes reference: every parameter, every error code, the on-disk format
- Runnable example: define, insert, edit, and round-trip a citation with a payload
- Content controls: the built-in control surface these build on