Realtime collaboration
Sync DocxEditor across users with Yjs, live cursors, presence, comment sync, and tracked changes attribution.
Bind DocxEditor to a Yjs document to get live multi-user editing: cursors, presence, comment sync, and tracked-change attribution.
Try it in 30 seconds: open the collaboration demo, share the URL with a tab or a colleague. (Or visit the online editor and click Collaborate.)
How it works
The editor exposes three props that hand off state to a CRDT:
| Prop | What it does |
|---|---|
externalContent | Skip the built-in document loader. Yjs will populate the doc instead. |
externalPlugins | Pass Yjs's ProseMirror plugins (ySyncPlugin, yCursorPlugin, yUndoPlugin). |
comments + onCommentsChange | Controlled comment threads, mirrored to a Y.Array on the same Y.Doc. |
Tracked changes sync through ySyncPlugin because they are stored as ProseMirror marks.
Step 1. Install
npm install yjs y-prosemirror y-webrtcy-webrtc uses public signaling servers, so it is useful for local tests and prototypes. For application deployments, use a provider you operate or a hosted provider such as PartyKit, Liveblocks, or Hocuspocus.
Step 2. Set up a shared Y.Doc
// useCollaboration.ts
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror';
import type { Plugin } from 'prosemirror-state';
export function useCollaboration(roomName: string, user: { name: string; color: string }) {
// Construct the provider in an effect (not useMemo): y-webrtc registers the
// room as a side effect, and an aborted render would leak it.
const [state, setState] = useState<{
provider: WebrtcProvider;
plugins: Plugin[];
} | null>(null);
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(roomName, ydoc);
const fragment = ydoc.getXmlFragment('prosemirror');
const plugins = [
ySyncPlugin(fragment), // syncs the PM doc
yCursorPlugin(provider.awareness), // remote cursors
yUndoPlugin(), // shared undo/redo
];
setState({ provider, plugins });
return () => {
provider.destroy();
ydoc.destroy();
setState(null);
};
}, [roomName]);
// Publish identity so peers can render avatars and labelled cursors.
useEffect(() => {
state?.provider.awareness.setLocalStateField('user', user);
}, [state, user.name, user.color]);
return state?.plugins ?? null;
}The hook returns null until the provider is ready; guard the editor render on it (shown in Step 3).
Step 3. Pass it to DocxEditor
import { DocxEditor, createEmptyDocument } from '@eigenpal/docx-editor-react';
import { useCollaboration } from './useCollaboration';
export function CollaborativeEditor({ room, user }) {
const plugins = useCollaboration(room, user);
// Hold the editor mount until the Y.Doc + provider are ready: ySyncPlugin
// has to attach on initial EditorState construction; swapping plugins in
// later doesn't repopulate the doc.
if (!plugins) return <div>Joining room…</div>;
return (
<DocxEditor
document={createEmptyDocument()} // schema seed. Yjs owns the content
externalContent
externalPlugins={plugins}
author={user.name}
showToolbar
showRuler
/>
);
}Open the page in two tabs with the same room, type in one, watch the other update with a labelled cursor showing the remote user's color.
Adding comment sync
Comment threads (text, replies, resolved status) live outside the PM doc. Mirror them through the controlled comments API to a Y.Array on the same Y.Doc.
Two small changes to Step 2's hook first: keep the Y.Doc in the state object (setState({ provider, plugins, ydoc })) and return it alongside the plugins (return { plugins: state?.plugins ?? null, ydoc: state?.ydoc ?? null }). Step 3's call becomes const { plugins, ydoc } = useCollaboration(room, user);. Then this self-contained hook does the mirroring:
// useSharedComments.ts
import { useCallback, useEffect, useState } from 'react';
import type * as Y from 'yjs';
import type { Comment } from '@eigenpal/docx-editor-core';
export function useSharedComments(ydoc: Y.Doc | null) {
const [comments, setCommentsState] = useState<Comment[]>([]);
// Mirror Y.Array → React state.
useEffect(() => {
if (!ydoc) return;
const yComments = ydoc.getArray<Comment>('comments');
const sync = () => setCommentsState(yComments.toArray());
sync();
yComments.observeDeep(sync);
return () => yComments.unobserveDeep(sync);
}, [ydoc]);
// Push React state → Y.Array.
const setComments = useCallback(
(next: Comment[]) => {
if (!ydoc) return;
const yComments = ydoc.getArray<Comment>('comments');
ydoc.transact(() => {
yComments.delete(0, yComments.length);
yComments.push(next);
});
},
[ydoc]
);
return { comments, setComments };
}Then pass to the editor:
const { comments, setComments } = useSharedComments(ydoc);
<DocxEditor
/* …other props… */
comments={comments}
onCommentsChange={setComments}
/>Production tip: the snippet above replaces the entire array on every change. For high-concurrency rooms, store comments in a
Y.Map<commentId, Comment>and apply diffs by id; that avoids two users clobbering each other's edits.
Production providers
y-webrtc is fine for demos: peers connect directly and there's no server. Real apps want a backed provider with persistence and access control:
| Provider | Best for | Swap |
|---|---|---|
y-partykit (Cloudflare) | Edge-hosted rooms with storage | new YPartyKitProvider(host, room, ydoc) |
@liveblocks/yjs | Managed presence + auth | new LiveblocksYjsProvider(room, ydoc) |
@hocuspocus/provider | Self-hosted Node.js server | new HocuspocusProvider({ url, name, document }) |
y-websocket | Roll-your-own WebSocket server | new WebsocketProvider(url, room, ydoc) |
All four implement the same Yjs provider role as WebrtcProvider, but their auth, persistence, and deployment setup differ. For example, switching to PartyKit means installing y-partykit partysocket, deploying a small party/server.ts that delegates to y-partykit's onConnect, and changing the provider constructor in the hook:
import YPartyKitProvider from 'y-partykit/provider';
const provider = new YPartyKitProvider('docx-collab.your-account.partykit.dev', roomName, ydoc);Full example
A complete runnable example (full hook, AvatarStack, identity helper, App.tsx wiring) lives in the repo:
The same code powers the Collaborate button in the online editor on this site. Direct deep-link: /editor?collaborate=1.
Next steps
- Comments for the controlled comments API the sync builds on
- Tracked changes for revision attribution and review
- examples/collaboration: the full runnable starter
- React props for
externalContent,externalPlugins, andcomments
Headless processing
Parse, inspect, mutate, and serialize DOCX files in Node.js or workers with @eigenpal/docx-editor-core/headless. No DOM and no editor UI required.
Overview
Let a model read and edit DOCX: comments, tracked changes, redlining. Run against a live editor, headless in Node, or over MCP.