@eigenpal/nuxt-docx-editor is the official Nuxt module for the docx-editor. Install it, add one line to nuxt.config.ts, and <DocxEditor> is auto-imported as an SSR-safe component, with no <ClientOnly> wrapper and no Vite config to write. The editor parses Word OOXML in the browser, so documents never upload to a server. This post covers the module setup, loading and saving .docx, the module options, tracked changes, comments, realtime collaboration, and AI agents.
Live demo
The editor below is the same component the module registers in your Nuxt app. Edit the sample, use the toolbar, or open your own .docx:
Install
npm install @eigenpal/nuxt-docx-editorThe module wraps @eigenpal/docx-editor-vue (the Vue 3 adapter) and pulls it in transitively, along with @eigenpal/docx-editor-core and @eigenpal/docx-editor-i18n. Add @eigenpal/docx-editor-agents if you want the AI agent toolkit.
Register the module
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@eigenpal/nuxt-docx-editor"],
});That is the whole setup. The module registers <DocxEditor> as a client-only component, auto-imports the Vue composables, and pushes the editor stylesheet into Nuxt's CSS pipeline. It works on Nuxt 3 and Nuxt 4.
Embed <DocxEditor> in a Nuxt page
<!-- pages/editor.vue -->
<script setup lang="ts">
import { ref } from "vue";
const buf = ref<ArrayBuffer | null>(null);
</script>
<template>
<DocxEditor :document-buffer="buf" />
</template>No import line, no <ClientOnly>. The module auto-imports <DocxEditor> and already registered it client-only, so it never runs during SSR: Nuxt renders a placeholder on the server and hydrates the editor in the browser. A null buffer mounts an empty document. Pass an ArrayBuffer to load a real .docx file.
Why the editor is client-only
ProseMirror, the engine under the editor, measures DOM geometry when it mounts, and there is no DOM on the server. Without the module you would do two things by hand: wrap the component in <ClientOnly>, and add the editor packages to Vite's dependency optimizer (without that, Nuxt's auto-import transform crashes the client with a duplicate-identifier error).
The module does both. It registers the component client-only and forces the editor packages through Vite's optimizeDeps. The result is that <DocxEditor> behaves like any other Nuxt component.
Module options
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@eigenpal/nuxt-docx-editor"],
docxEditor: {
prefix: "Ep", // register <EpDocxEditor> instead of <DocxEditor>
injectStyles: true, // set false to import the stylesheet yourself
},
});| Option | Type | Default | Description |
|---|---|---|---|
prefix | string | '' | Component name prefix. Ep registers <EpDocxEditor>. |
injectStyles | boolean | true | Pushes the editor stylesheet into Nuxt's CSS. Set false to import @eigenpal/docx-editor-vue/styles.css yourself. |
Types and other surfaces
The module re-exports the <DocxEditor> component and the Vue composables, not the whole adapter. The DocxEditorProps and DocxEditorRef types, renderAsync, createEmptyDocument, and the /ui, /dialogs, and /plugin-api subpaths come from @eigenpal/docx-editor-vue directly. If you use any of them, add the adapter to your own dependencies so the import is explicit:
npm install @eigenpal/docx-editor-vueimport { createEmptyDocument } from "@eigenpal/docx-editor-vue";
import type { DocxEditorRef } from "@eigenpal/docx-editor-vue";Load a .docx file
document-buffer takes an ArrayBuffer. Fetch a file in onMounted so the request never fires during SSR:
<!-- pages/editor.vue -->
<script setup lang="ts">
import { ref, onMounted } from "vue";
const buf = ref<ArrayBuffer | null>(null);
onMounted(async () => {
buf.value = await fetch("/contract.docx").then((r) => r.arrayBuffer());
});
</script>
<template>
<DocxEditor :document-buffer="buf" />
</template>Drop the file in public/ and Nuxt serves it at the root path. Or let users open their own documents with a file picker:
<script setup lang="ts">
import { ref } from "vue";
const buf = ref<ArrayBuffer | null>(null);
async function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) buf.value = await file.arrayBuffer();
}
</script>
<template>
<input type="file" accept=".docx" @change="onFile" />
<DocxEditor v-if="buf" :document-buffer="buf" />
</template>The editor parses OOXML in the browser. No upload, no server round-trip. The user's document stays on their machine.
Save a .docx file
<DocxEditor> exposes an imperative handle. Grab it with useTemplateRef, call save(), and you get an ArrayBuffer of OOXML bytes back:
<script setup lang="ts">
import { ref, useTemplateRef } from "vue";
import type { DocxEditorRef } from "@eigenpal/docx-editor-vue";
const buf = ref<ArrayBuffer | null>(null);
const editor = useTemplateRef<DocxEditorRef>("editor");
async function save() {
const out = await editor.value?.save();
if (!out) return;
const blob = new Blob([out], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const a = Object.assign(document.createElement("a"), {
href: URL.createObjectURL(blob),
download: "edited.docx",
});
a.click();
URL.revokeObjectURL(a.href);
}
</script>
<template>
<button @click="save">Download .docx</button>
<DocxEditor ref="editor" :document-buffer="buf" />
</template>save() returns the same OOXML format the editor reads. The round-trip is lossless: open a Word document, edit it, save, reopen in Word, no fidelity loss.
To persist the document server-side instead of downloading it, POST the buffer to a Nitro route:
// server/api/documents.post.ts
export default defineEventHandler(async (event) => {
const body = await readRawBody(event, false); // Buffer of OOXML bytes
// upload to S3, save to a database, etc.
return { ok: true };
});server/api/ is Nuxt's built-in backend. On the client, take the save() result and post it as a Blob: await $fetch("/api/documents", { method: "POST", body: new Blob([out]) }).
Composables are auto-imported
Every composable from @eigenpal/docx-editor-vue/composables (useDocxEditor, useTrackedChanges, useFindReplace, useAutoSave, and the rest) is auto-imported by the module. Use them in any page or component with no import:
<script setup lang="ts">
const { save } = useAutoSave(/* ... */);
</script>What's included
- Round-trip fidelity. Open a Word
.docx, edit, save, reopen in Word. Tables, headers, footers, footnotes, images, tracked changes, comments, page layout. No quality loss. - Tracked changes. Suggesting mode wraps every edit as a revision marker with author attribution. Accept or reject individually or in bulk.
- Comments. Threaded comments anchored to text ranges. Add, reply, resolve, delete.
- Realtime collaboration. Wire to a Yjs document for live multi-user editing with cursors and presence. Works with y-webrtc (zero infra), PartyKit, Liveblocks, or any Yjs provider.
- Document templates. A
templatePluginhighlights{{variable}}placeholders. Fill them programmatically or through the built-in panel. - Plugins. Extensible architecture with ProseMirror plugins for custom toolbar actions, keyboard shortcuts, and document transformations.
- i18n. Built-in localization for English, Polish, German, Brazilian Portuguese, Hebrew, Turkish, and Simplified Chinese, with per-locale code-splitting.
Tracked changes in Nuxt
Set mode to "suggesting" and every edit is recorded as a tracked change instead of a direct edit:
<script setup lang="ts">
import { ref } from "vue";
import type { EditorMode } from "@eigenpal/docx-editor-vue";
const props = defineProps<{ buf: ArrayBuffer }>();
const mode = ref<EditorMode>("suggesting");
</script>
<template>
<DocxEditor
:document-buffer="props.buf"
:mode="mode"
@mode-change="(m) => (mode = m)"
/>
</template>@mode-change keeps the local mode ref in sync when the user flips the toolbar toggle. Tracked changes round-trip through the .docx, so the document owner can accept or reject them later, in the editor or in Word.
Comments
The editor renders threaded comments anchored to text ranges, and they round-trip through the .docx: open a Word document with comments, edit it, save, and the threads come back intact in Word. Comments live in the document model rather than on a component prop, so they travel with the file and through save(). Read and write them through the DocxEditorRef methods and the @change event. See the Vue API reference for the comment methods.
Realtime collaboration (Yjs)
The module makes <DocxEditor> client-only, but a Yjs provider you construct in <script setup> still runs during SSR. Put the collaborative editor in a .client.vue component so Nuxt keeps the whole thing in the browser:
<!-- components/CollabEditor.client.vue -->
<script setup lang="ts">
import { onUnmounted } from "vue";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from "y-prosemirror";
const props = defineProps<{ buf: ArrayBuffer }>();
const ydoc = new Y.Doc();
const provider = new WebrtcProvider("room-1", ydoc);
const yXml = ydoc.getXmlFragment("docx");
const plugins = [
ySyncPlugin(yXml),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
];
onUnmounted(() => provider.destroy());
</script>
<template>
<DocxEditor :document-buffer="props.buf" :external-plugins="plugins" />
</template><DocxEditor> is still auto-imported here, no import line. The .client.vue suffix keeps the provider and the Yjs document off the server. Live cursors, presence, and conflict-free merging work against any Yjs provider: y-webrtc (zero infra), PartyKit, Liveblocks, or your own WebSocket backend.
AI agents driving the editor
@eigenpal/docx-editor-agents connects an AI agent to the editor: the agent reads the document, then lands comments and tracked changes as the model streams tokens. The Vue integration is useAgentBridge from @eigenpal/docx-editor-agents/vue, wired to a DocxEditorRef. The chat state is browser-only, so the component is .client.vue:
<!-- components/AgentEditor.client.vue -->
<script setup lang="ts">
import { useTemplateRef } from "vue";
import type { DocxEditorRef } from "@eigenpal/docx-editor-vue";
import { useAgentBridge } from "@eigenpal/docx-editor-agents/vue";
const props = defineProps<{ buf: ArrayBuffer }>();
const editor = useTemplateRef<DocxEditorRef>("editor");
const { executeToolCall, toolSchemas } = useAgentBridge({ editorRef: editor });
</script>
<template>
<DocxEditor ref="editor" :document-buffer="props.buf" />
</template>useAgentBridge returns executeToolCall (runs a tool call against the live editor) and toolSchemas (the function-calling schemas for the model). The server route pairs them with getAiSdkTools() from @eigenpal/docx-editor-agents/ai-sdk/server, which works with any OpenAI, Anthropic, or Vercel-AI-SDK-compatible model. Agents → Live editor has the complete client and server wiring.
Ask the agent below to review the document. Its comments and suggestions land in the editor as it works:
Open source, Apache 2.0
@eigenpal/nuxt-docx-editor is open source under Apache 2.0. Use it in personal and commercial projects, modify the source, redistribute. No usage limits, no watermarks, no premium tier. The source lives at github.com/eigenpal/docx-editor in packages/nuxt/, and a runnable example is in examples/nuxt.
Where to next
- Nuxt module reference: install, options, and what to import from the adapter directly
- Nuxt example on GitHub: the full playground this post is based on
- Vue package overview: the adapter the module wraps, and the place to start on plain Vue 3
- Vue 3 editor guide: every prop, event, slot, and composable
- Next.js DOCX editor: the React adapter, if you also run a Next.js app
- Agents → Live editor: full client and server wiring for the AI toolkit
- 1.0 release post: what shipped in 1.0