Developer guide · Word editing

Build a JavaScript DOCX editor

Install @onodocs/editor to give your application a Word editor with a ribbon, text selection, formatting, undo/redo and DOCX downloads. Documents are processed in the browser. This guide takes you through opening a file, editing it, saving and reopening it, then connecting your application's storage and React lifecycle.

Complete editor sample on GitHub · Guide source · Try the editor

Run the editor locally

You need Git, Node.js 22.18 or newer, and npm. The public repository supplies the application, build setup and sample Word files. Evaluation needs no account or license key.

git clone https://github.com/onodocs/onodocs.git
cd onodocs
npm run setup
npm start

Open http://127.0.0.1:5174/samples/editor/. You should see a project report and the editor ribbon, with an evaluation watermark. Keep the terminal running; Ctrl+C stops the server. For a single sample, run node examples/serve-workflow.mjs editor after setup and open its printed address. See the complete sample instructions.

Open a DOCX, edit, save and reopen it

  1. Use the included project report or choose Open Word in the File ribbon to select your own DOCX. It is read locally.
  2. Click a paragraph and type. Select text to apply formatting. Try Undo and Redo to check the editing flow.
  3. Choose Download Word from File. Your browser downloads a new DOCX containing the current edits; the original file is unchanged.
  4. Use Open Word to open that downloaded file and verify your edits. You can also open it in Word or LibreOffice to assess your document's interchange needs.

Save before opening another document or leaving the page. This standalone sample keeps working edits in the tab; a download does not create a server copy or a recoverable browser draft. Try this journey in the hosted editor.

Install in an existing JavaScript or TypeScript application

npm install @onodocs/editor

Editor includes TypeScript declarations and installs matching SDK and Canvas dependencies. SDK owns document processing; Canvas displays pages; Editor adds the editing interface. Use your application's browser bundler. If you also install SDK or Canvas directly, keep their versions aligned with Editor. Editor on npm.

The code below goes in your existing application. If you do not have an application shell, start with the complete runnable sample, which already includes HTML, styles and bundling. Deploy all emitted assets, including lazy PDF chunks. Browser editing does not require Playwright or a rendering server.

Embed the editor with local opening and downloads

Add an empty host and a visible status region to your page. Give the host a usable height:

<div id="editor" style="height: 80vh; min-height: 24rem"></div>
<p id="status" role="status"></p>

Use this in your bundled main.ts. Put a DOCX at your application's /sample.docx URL, or download the sample report and serve it there.

import { createEditor } from "@onodocs/editor";

const lifetime = new AbortController();
const status = document.querySelector<HTMLElement>("#status")!;
const editor = createEditor({
  container: document.querySelector<HTMLElement>("#editor")!,
  document: { signal: lifetime.signal },
  allowedModes: ["view", "edit"],
  onError: showError
});

function showError(error: unknown) {
  if (!lifetime.signal.aborted) {
    status.textContent = error instanceof Error ? error.message : "Unable to complete the operation.";
  }
}

async function openSample() {
  status.textContent = "Opening document…";
  try {
    const response = await fetch("/sample.docx", { signal: lifetime.signal });
    if (!response.ok) throw new Error(`Open failed (${response.status})`);
    const bytes = await response.arrayBuffer();
    lifetime.signal.throwIfAborted();
    await editor.open(bytes, "sample.docx");
    if (!lifetime.signal.aborted) status.textContent = "Ready";
  } catch (error) { showError(error); }
}

export function disposeEditor() {
  lifetime.abort();
  editor.dispose();
}

window.addEventListener("pagehide", event => {
  if (!event.persisted) disposeEditor();
});
void openSample();

The ribbon supplies local opening, editing, undo/redo and downloads. A failed initial fetch leaves the ribbon available to open another file. Call disposeEditor() before removing the host in a single-page application. It aborts loading and releases the editor; it does not save edits. The complete sample adds a sample picker and confirmation before switching samples.

For your own controls, await editor.open(file, file.name) accepts a local File. await editor.save() returns DOCX bytes; it does not itself download or store them. Use editor.execute(command), editor.undo() and editor.redo() for changes with editor history.

Connect saving to your application

Use @onodocs/editor/application when the document belongs to a user or record in your application. It tracks unsaved changes and calls your persist callback. A successful application.save() means that callback acknowledged storage. The ribbon's Download Word action still only downloads a local copy.

Copy mount.ts beside your module. This sample helper imports the editor after mounting and returns immediate cancellation and cleanup. It is source to copy, not a separate npm package.

Save the following function as stored-editor.ts; it replaces the local-file integration above. Supply an empty editor host, a status element, a Save button and your document endpoint. Your server must implement authenticated GET returning DOCX bytes and PUT storing the request's DOCX bytes, with non-success responses for failures. These endpoints are your application code.

import { mountEditor } from "./mount";

export function mountStoredEditor(container: HTMLElement, status: HTMLElement, saveButton: HTMLButtonElement, url: string) {
  const lifetime = new AbortController();
  let stop = () => {};
  let ready = false;
  saveButton.disabled = true;
  status.textContent = "Opening document…";

  function showError(error: unknown) {
    if (!lifetime.signal.aborted) {
      status.textContent = error instanceof Error ? error.message : "Unable to complete the operation.";
    }
  }

  async function open() {
    const response = await fetch(url, { credentials: "same-origin", signal: lifetime.signal });
    if (!response.ok) throw new Error(`Open failed (${response.status})`);
    const input = await response.arrayBuffer();
    lifetime.signal.throwIfAborted();
    stop = mountEditor(container, {
      input, name: "document.docx", autosaveDelay: false,
      allowedModes: ["view", "edit"],
      async persist(bytes, signal) {
        const saved = await fetch(url, {
          method: "PUT", credentials: "same-origin", signal,
          headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
          body: bytes
        });
        if (!saved.ok) throw new Error(`Save failed (${saved.status})`);
      },
      onEvent(event) {
        if (lifetime.signal.aborted) return;
        saveButton.disabled = !ready || event.state.saving;
        if (event.type === "error") showError(event.error);
        else status.textContent = event.state.saving ? "Saving…" : event.state.dirty ? "Unsaved changes" : event.type === "ready" ? "Ready" : "Saved";
      }
    }, application => {
      ready = true;
      saveButton.disabled = false;
      saveButton.addEventListener("click", () => {
        void application.save().catch(showError);
      }, { signal: lifetime.signal });
    });
  }

  void open().catch(showError);
  return () => {
    lifetime.abort();
    stop();
    saveButton.disabled = true;
  };
}

Call mountStoredEditor(host, status, saveButton, "/api/documents/123") after creating those DOM elements, and call its returned cleanup before removing the host or changing the document. Edit, press Save, wait for storage to succeed, then reopen the same record in a new session. Its GET response should contain the saved edits.

This example uses manual saving. A failed PUT leaves changes available for retry. Edits made during saving remain dirty until their own save succeeds. Your server owns authorization, durable storage and CSRF protection. For concurrent writers, add your application's version or ETag to the request and reject conflicts; offer reload, save-as or explicit resolution for 409/412 responses. A simple PUT alone does not prevent one writer from overwriting another.

Keep the application session's default restriction on Open/New controls. Create a new session when the user or document identity changes; calling application.editor.open() directly bypasses session tracking. Direct document mutations also bypass the editor's undo history; after a successful direct mutation, call application.markChanged() to publish its dirty state.

Autosave, recovery and leaving the page

Omit autosaveDelay: false to debounce application saves by 1000 milliseconds, or set a nonnegative delay. The optional recovery store provides asynchronous read, write and remove methods. The existing IndexedDB helper supplies one implementation. Scope its key to the authenticated user and document, and ask before restoring with restoreRecovery: true when a draft may conflict with server content.

Recovery checkpoints are written when saving starts. Abrupt termination can lose changes made inside the debounce window. Disposal aborts work and retains an existing checkpoint; it does not flush unsaved changes. Warn while dirty and await saving before application navigation. Do not rely on asynchronous saving during beforeunload. Choose draft retention and logout cleanup for your application's privacy requirements.

Use the editor in React or Next.js

Copy the React Editor component and its sibling mount.js from the public repository. TypeScript applications can use mount.ts. The component returns the helper's cleanup from its effect, covering replacement, unmount and React Strict Mode.

In a parent component, memoize options and onReady so unrelated renders preserve the editing session. Here input is the fetched document, and persist is your stable storage callback using the PUT behavior above:

import { useMemo } from "react";
import { Editor } from "./Editor";

export function DocumentEditor({ input, persist }) {
  const options = useMemo(() => ({ input, persist }), [input, persist]);
  return <div className="editor-host"><Editor options={options} /></div>;
}
.editor-host > div { height: 80vh; min-height: 24rem; }

This uses default autosave. Add onEvent to the memoized options to display errors and dirty/saving status; use the component's onReady callback to retain the application handle for an explicit Save button. Save or confirm discarding edits before replacing input. Keep the document bytes and persistence callback stable, and create a new session when document identity changes.

For Next.js, place this integration behind "use client". The helper dynamically imports the browser editor inside the effect, allowing the initial page shell to render on the server. Fetch document input with cancellation and keep File objects in client state. React owns the surrounding UI; leave the editor host empty for the editor to manage. The framework projects provide complete application shells for React, Next.js, Vue, Svelte, Angular and .NET.

Local processing, evaluation and licensing

The standalone sample processes local documents in the browser and does not upload their contents or filenames. Connecting persist sends the saved bytes to the endpoint you supply. The hosted demo records usage events only with consent under our privacy policy; downloaded samples and SDK packages contain no analytics. Scripts and fonts still need to load, so cache required assets for offline applications.

Editor source is MIT-licensed. Its SDK and Canvas dependencies require a commercial application license for production; free non-production evaluation needs no key and adds a watermark. Trial and commercial keys go in document: { licenseKey } for either editor API. The issued signed token may be included in your browser bundle and is verified offline over HTTPS or localhost. See pricing and trials and the application license terms.

Check supported editing with your documents

Common text formatting, lists, links, regular tables and inline-image properties are supported. Rich paste preserves common text formatting and lists; pasted tables and images become readable text with a notice. Floating-image manipulation and merged-table restructuring remain limited. Font availability affects pagination and appearance; see font setup. Rendering can differ from Word.

Password-protected files and older DOC or RTF formats need to be unlocked or converted first. Review features have their own limits: imported paragraph-mark and table-row revision decisions require Word. PDF export offers searchable text, links and supported tags, with supported vector graphics and rasterized visible text; it is not PDF/UA certified. Test opening, editing, saving and reopening representative documents before choosing the editor for your workflow.

If opening fails, display the rejected error and let the user retry. If saving fails, retain the active session and retry storage. A blank or clipped host needs a usable height; missing content or different page breaks may need the document's fonts. Report reproducible document issues with minimal fictional examples in GitHub Issues.

Extend the document workflow

Continue with templates, forms, agreement review and collaboration. For viewing without editing, use the React DOCX viewer guide or JavaScript viewer guide. To customize the editor's ribbon and controls, follow the Editor source guide.