TutorialMarch 19, 20263 min read

Remix DOCX editor: edit Word documents in a Remix app

Add a DOCX editor to a Remix app. Load the React adapter on the client, open a .docx, edit it, and download the result.

To edit Word documents in a Remix app, install @docx-editor.dev/react and keep the editor out of server rendering. A Remix route runs on both the server and the client, so load the component lazily.

Live demo

Open your own .docx, or edit the sample document in the demo. The demo does not upload anything. The document stays in your browser.

Install

npm install @docx-editor.dev/react @docx-editor.dev/core

@docx-editor.dev/core is a peer dependency. The adapter bundles the string catalog.

Build the editor component

The editor uses browser APIs, so it runs only on the client. Put it in its own file:

// app/components/DocxEditor.tsx
import { useState, useEffect, useRef, useCallback } from "react";
import { DocxEditor } from "@docx-editor.dev/react";
import type { DocxEditorRef } from "@docx-editor.dev/react";
import "@docx-editor.dev/core/styles/editor.css";
 
const DOCX_MIME =
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
 
export function Editor() {
  const editorRef = useRef<DocxEditorRef>(null);
  const [bytes, setBytes] = useState<Uint8Array>();
  const [fileName, setFileName] = useState("Untitled.docx");
 
  useEffect(() => {
    fetch("/sample.docx")
      .then((res) => res.arrayBuffer())
      .then((buf) => {
        setBytes(new Uint8Array(buf));
        setFileName("sample.docx");
      })
      // Nothing to load: leave `document` undefined and the editor opens empty.
      .catch(() => undefined);
  }, []);
 
  const handleSave = useCallback(async () => {
    const saved = await editorRef.current?.save();
    if (!saved) return;
    const blob = new Blob([saved], { type: DOCX_MIME });
    const url = URL.createObjectURL(blob);
    Object.assign(document.createElement("a"), {
      href: url,
      download: fileName,
    }).click();
    URL.revokeObjectURL(url);
  }, [fileName]);
 
  return (
    <div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
      <button onClick={handleSave}>Download .docx</button>
      <DocxEditor
        ref={editorRef}
        document={bytes}
        title={fileName}
        onTitleChange={setFileName}
      />
    </div>
  );
}

Lazy-load in your route

A Remix route runs on both the server and the client. React.lazy and Suspense keep the editor on the client:

// app/routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { lazy, Suspense, useEffect, useState } from "react";
 
export const meta: MetaFunction = () => [
  { title: "DOCX Editor. Remix" },
  { name: "description", content: "Edit Word documents in the browser" },
];
 
const Editor = lazy(() =>
  import("../components/DocxEditor").then((m) => ({ default: m.Editor }))
);
 
function Loading() {
  return <div className="editor-loading">Loading the DOCX editor...</div>;
}
 
export default function Index() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
 
  if (!mounted) {
    return <Loading />;
  }
 
  return (
    <Suspense fallback={<Loading />}>
      <Editor />
    </Suspense>
  );
}

The mounted check prevents a hydration mismatch. The first client render matches the server output. The editor mounts after that render.

Let users open a file

import { useState } from "react";
import { DocxEditor } from "@docx-editor.dev/react";
 
export function UploadEditor() {
  const [bytes, setBytes] = useState<Uint8Array>();
  const [fileName, setFileName] = useState("Untitled.docx");
 
  function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) return;
    void file.arrayBuffer().then((buffer) => {
      setBytes(new Uint8Array(buffer));
      setFileName(file.name);
    });
  }
 
  return (
    <div style={{ height: "100vh" }}>
      <input type="file" accept=".docx" onChange={handleUpload} />
      {bytes && <DocxEditor document={bytes} title={fileName} />}
    </div>
  );
}

Save through a Remix action

// app/routes/api.documents.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
 
export async function action({ request }: ActionFunctionArgs) {
  const data = await request.arrayBuffer();
  // upload to S3, save to DB, etc.
  return Response.json({ ok: true });
}
import { useRef } from "react";
import { DocxEditor, type DocxEditorRef } from "@docx-editor.dev/react";
 
export function SavingEditor({ bytes }: { bytes: Uint8Array }) {
  const editorRef = useRef<DocxEditorRef>(null);
 
  async function save() {
    const saved = await editorRef.current?.save();
    if (!saved) return;
    await fetch("/api/documents", { method: "POST", body: saved });
  }
 
  return (
    <>
      <button onClick={save}>Save</button>
      <DocxEditor ref={editorRef} document={bytes} />
    </>
  );
}

Common errors

ErrorFix
Hydration mismatchWrap the editor in a mounted check
Styles do not renderImport @docx-editor.dev/core/styles/editor.css in the editor component, not in the route
window is not definedUse React.lazy. Do not import the editor at the top of a route file

Included features

The editor parses OOXML in the browser and renders the document as pages. It supports:

  • Bold, italic, underline, and strikethrough
  • Tables, including merged cells
  • Inline and floating images
  • Headers, footers, and page breaks
  • Zoom and a document outline

It writes the document back to .docx. The editor is Apache 2.0 and needs no document-processing server. For more information about tracked changes, comments, and custom nodes, see the Pro module.

Where to go next