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
| Error | Fix |
|---|---|
| Hydration mismatch | Wrap the editor in a mounted check |
| Styles do not render | Import @docx-editor.dev/core/styles/editor.css in the editor component, not in the route |
window is not defined | Use 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
- Remix setup reference covers the same steps in the docs.
- Remix example on GitHub is a runnable project.
- Tracked changes and Comments cover review workflows.
- React props reference documents every prop.