TutorialMarch 19, 2026·2 min read

Remix DOCX Editor: Edit Word Documents in a Remix App

Add a browser-based DOCX editor to your Remix app. Client-side Word document editing with lazy loading and Suspense. Includes live demo and full setup guide.

Live demo

Upload a .docx or edit the sample below. Nothing leaves your browser.

Install

npm install @eigenpal/docx-js-editor

Editor component

The editor uses browser APIs so it needs to run client-only. Separate file:

// app/components/DocxEditor.tsx
import { useState, useEffect, useRef, useCallback } from "react";
import {
  DocxEditor,
  createEmptyDocument,
} from "@eigenpal/docx-js-editor";
import type { DocxEditorRef, Document } from "@eigenpal/docx-js-editor";
import "@eigenpal/docx-js-editor/styles.css";
 
export function Editor() {
  const editorRef = useRef<DocxEditorRef>(null);
  const [documentBuffer, setDocumentBuffer] = useState<ArrayBuffer | null>(null);
  const [currentDocument, setCurrentDocument] = useState<Document | null>(null);
  const [fileName, setFileName] = useState("sample.docx");
 
  useEffect(() => {
    fetch("/sample.docx")
      .then((res) => res.arrayBuffer())
      .then((buf) => {
        setDocumentBuffer(buf);
        setFileName("sample.docx");
      })
      .catch(() => {
        setCurrentDocument(createEmptyDocument());
        setFileName("Untitled.docx");
      });
  }, []);
 
  const handleSave = useCallback(async () => {
    const saved = await editorRef.current?.save();
    if (!saved) return;
    const blob = new Blob([saved], {
      type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    });
    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" }}>
      <DocxEditor
        ref={editorRef}
        document={documentBuffer ? undefined : currentDocument}
        documentBuffer={documentBuffer}
        showToolbar
        showRuler
        showZoomControl
      />
    </div>
  );
}

Lazy-load in your route

Remix routes run on both server and client. React.lazy + Suspense keeps the editor client-only:

// 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 }))
);
 
export default function Index() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
 
  if (!mounted) {
    return <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100vh", color: "#666" }}>Loading DOCX Editor...</div>;
  }
 
  return (
    <Suspense fallback={<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100vh", color: "#666" }}>Loading DOCX Editor...</div>}>
      <Editor />
    </Suspense>
  );
}

The mounted check prevents hydration mismatches.

File uploads

function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
  const file = e.target.files?.[0];
  if (!file) return;
  file.arrayBuffer().then((buf) => {
    setDocumentBuffer(buf);
    setFileName(file.name);
  });
}

Save via 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 });
}
const saved = await editorRef.current?.save();
await fetch("/api/documents", { method: "POST", body: saved });

Common errors

ErrorFix
Hydration mismatchWrap the editor in a mounted check as shown above
Styles not renderingImport @eigenpal/docx-js-editor/styles.css in the Editor component, not in the route
window is not definedUse React.lazy. Don't import the editor at the top of a route file

What you get

The editor parses OOXML on the client and renders via ProseMirror. Out of the box: bold/italic/underline, tables with cell merging, inline images, headers and footers, page breaks, tracked changes, threaded comments, zoom, and document outline. It exports back to valid .docx. MIT licensed, ~200KB gzipped, no server dependency.

Next steps