Revise Developer Platform menu

Collaboration

Real-time multi-user editing over your own transport, with your server as a participant.

These docs are for version 2.3.x. See the latest version.

Every document is a Yjs CRDT from the start. A single-user editor is a room with one participant. This gives you:

  • Real-time editing over your own transport. Carets, presence, tracked changes, and comments are collaborative by default. Your authentication and infrastructure carry the traffic.
  • Word round trip after concurrent edits. Edits merge as CRDT operations, and the result exports to .docx with native tracked changes and comments.
  • A server in the same room. @reviseio/editor/server runs the same converters and document tools in Node. Your backend can create documents before anyone opens them, edit live rooms without a browser, and propose tracked changes for users to review.

The SDK never opens a socket. You own the transport, its authentication, and its lifecycle. Pass the provider to make the document collaborative:

TSX
import { HocuspocusProvider } from "@hocuspocus/provider";

const provider = new HocuspocusProvider({
  url: "wss://collab.example.com",
  name: "contract-1",
});

<ReviseEditor
  currentUser={{ id: user.id, name: user.name, image: user.avatarUrl }}
  initialDocuments={[
    {
      id: "contract-1",
      source: file,
      collaboration: { provider, synced },
    },
  ]}
/>;

The editor edits the provider's document in place. The provider's awareness carries carets and presence. The provider object marks which edits came from other people.

One copy of Yjs#

Yjs identifies its own types with instanceof, so your application and the SDK must resolve to the same Yjs module. Yjs is a peer dependency. Install it yourself:

Terminal
npm install yjs y-protocols

If the bundler gives each side a different copy, seeding a shared document fails with Unexpected content type in insert operation. In Vite, keep the SDK and Yjs on the same side of the dependency optimizer:

vite.config.ts
export default defineConfig({
  resolve: { dedupe: ["yjs", "y-protocols"] },
  optimizeDeps: { exclude: ["@reviseio/editor", "yjs", "y-protocols"] },
});

Webpack and Next.js resolve a single copy when your lockfile has only one yjs. Run npm ls yjs to check.

Connect callback#

The editor reserves eight top-level keys in the Yjs document (content, chrome, notes, comments, annotations, metadata, sdt-wrappers, paragraphs) and needs specific document settings. The recommended form gives you a document and an awareness instance to connect:

TypeScript
collaboration: {
  connect: (doc, awareness) =>
    new HocuspocusProvider({ url, name: "contract-1", document: doc, awareness }),
  synced,
}

Return the provider. The editor uses it to recognize edits from peers. Your application still owns the connection, its authentication, and its lifetime.

Supplying a Y.Doc#

Some transports create the document themselves. Both of these forms work:

TypeScript
collaboration: { provider }            // reads document/doc/getYDoc() + awareness
collaboration: { ydoc, awareness, remoteOrigin }

The editor checks a supplied document before writing to it:

Document stateResult
EmptySeeded
Holds a Revise documentJoined
Reserved keys hold other dataRefused

A refused document reports the conflicting key:

Code
Revise collaboration: this Y.Doc already uses "metadata", which the editor
reserves for its own document.

This happens if your application keeps one Yjs document per room for all of its data and already uses a name such as comments or metadata. Give the editor its own document, or keep your data in a separate one.

Use the explicit form for an unrecognized transport, a custom sync layer, or a setup where the document and the connection are separate objects:

TypeScript
collaboration: {
  ydoc,
  awareness,                 // omit or pass null for no presence
  remoteOrigin: myTransport, // see below
  synced: isSynced,
  seed: "if-empty",
}

The explicit form requires remoteOrigin: the transaction origin your transport passes to Y.applyUpdate. Without it, the editor treats a peer's edit as local. Cursor restoration breaks, and Ctrl+Z can undo someone else's typing. A transport that applies updates with a null origin must be wrapped to tag its own transactions. Passing provider handles this, since most providers pass themselves as the origin.

Seeding#

The first participant writes the content. Everyone else joins it. seed controls this:

ValueBehavior
"if-empty" (default)If the shared document is empty, the source document is written into it. Otherwise the source is ignored and the editor joins the existing content.
"never"Always join. source is not needed, and a document input with no source is valid.
TypeScript
{ id: "contract-1", collaboration: { provider, synced, seed: "never" } }

Seeding happens after the first sync. Seeding before receiving the room's state would duplicate the document.

Sync state#

Set synced to true once the provider has received the room's initial state. Until then, the editor keeps the document closed instead of showing an empty, editable page:

TSX
const [synced, setSynced] = useState(false);

useEffect(() => {
  provider.on("synced", () => setSynced(true));
}, [provider]);

Later disconnections do not close the document. Yjs merges offline edits when the transport reconnects. A dropped connection affects presence only.

If your transport has no sync signal, omit synced. The editor opens immediately, and a brand-new room can be seeded twice.

Roster#

collaboration.getState() returns awareness as a list of peers, with the same colors the carets use:

TypeScript
const { enabled, synced, peers } = editor.collaboration.getState();
// peers: [{ clientId, isLocal, id, name, email, image, color }]

useEffect(() => editor.collaboration.subscribe(setPresence), [editor]);

It fires on join, leave, caret movement, and sync state changes. A participant with no currentUser does not appear.

Presence and cursors#

Remote carets render when awareness is available, with a label and color per participant. Peers see what you pass as currentUser:

TSX
<ReviseEditor
  currentUser={{
    id: user.id,
    name: user.name,
    email: user.email,
    image: user.avatarUrl, // drawn on the caret flag
    color: user.brandColor, // caret, flag, and roster color
  }}
/>

name labels the caret. image is drawn on its flag and can be any URL the browser can load, including a data: URI.

color is optional. Without it, the editor derives a stable color from the user id, so each person has the same color for everyone. Set it to use your own palette or to match an avatar color.

A participant with no currentUser has no caret for others to see. Pass one for anyone who should be visible.

currentUser also sets the author of tracked changes. Without it, suggestions export to Word with the reviewer name "Anonymous".

Server editing#

With @reviseio/editor/server, your server is a peer in the room, not only a relay. The backend entry runs the same converters and mutation engine as the editor. Changes your server makes to the Y.Doc reach every open browser as ordinary updates, and browser changes reach your server the same way. The backend reference covers the full API. This section covers common workflows.

Seeding from your own server#

Your server can create the room from a file it already has:

TypeScript
import { seedYDocFromFile, encodeYDoc, ydocToDocx } from "@reviseio/editor/server";

// When a room is first opened, before any client finishes syncing:
await seedYDocFromFile(ydoc, docxBytes, "agreement.docx");

// Store the encoded bytes however you like:
await store.put(roomId, encodeYDoc(ydoc));

// Export the live room without a browser:
const bytes = await ydocToDocx(ydoc);

Clients then pass seed: "never" and no source file, and join the existing document. This avoids a race where two people open a new document at the same time and both import it.

The @reviseio/editor/server converters parse XML and HTML with DOM APIs, so jsdom is a peer dependency of this entry point. For a worked example of a @reviseio/editor/server service editing the same live document as the browser, see reviseio/sdk-demo.

Editing a live room#

To change a room on the server, bind a document session to it. The session has the same document tools as the browser: read, search, measure, mutate, and comment. Edits go through the same Yjs-backed mutation engine, so peers receive ordinary updates and remote carets stay in place:

TypeScript
import { createServerDocumentSession } from "@reviseio/editor/server";

const session = await createServerDocumentSession(ydoc, {
  documentId: "agreement-1",
});
try {
  // Sessions default to suggesting, so this appears as a tracked change
  // in every open tab.
  const edit = await session.tools.call("replace", {
    search_result_id: searchResultId,
    replacements: [{ find: "Delaware", replace: "New York" }],
  });
  await notifyReviewers(edit.suggestionIds ?? []);
} finally {
  session.dispose(); // your Y.Doc stays alive
}

Sessions default to suggesting mode. A service proposes tracked changes unless it opts into direct edits with mode: "editing" or a per-call directMode. Nothing an unattended backend job proposes is applied until a person accepts it.

Review across server and browser#

Server and browser share the same tool API. tools.execute() returns the same ReviseToolResult, mutations report the same suggestionIds, and accept and reject return the same ReviseSuggestionDecision. The server can propose changes and a person can resolve them in the browser:

TypeScript
// Server: propose, and pass the IDs to your workflow.
const edit = await session.tools.call("replace", { ... });
await queueForReview(edit.suggestionIds ?? []);

// Browser: later, resolve that batch from your review UI.
const decision = editor.review.acceptSuggestions(await reviewBatch());
// { resolved, missing, unresolved }. Stale IDs are listed in `missing`.

It also works in reverse. A person suggests changes in the browser, and a scheduled job reads session.listSuggestions(). Each pending record includes its author, so the job can resolve its own agent's suggestions and leave human ones alone. See tracked changes for the review UI.

Working without a live transport#

A server can participate without a socket. For an API endpoint or a queue worker, the client posts its document as one encoded update. The server edits a decoded copy and returns only the delta. The delta merges into the live document like any peer's edit, even if the user kept typing in the meantime:

TypeScript
// Client
const response = await api.review({ update: Y.encodeStateAsUpdate(ydoc) });
Y.applyUpdate(ydoc, response.delta, "my-backend");

// Server
import { decodeYDoc } from "@reviseio/editor/server";

const ydoc = decodeYDoc(update);
const baseline = Y.encodeStateVector(ydoc);
// ...run a session, propose suggestions...
const delta = Y.encodeStateAsUpdate(ydoc, baseline);

The server keeps no state and needs no transport. The response carries the created suggestionIds for the same review flow as above.

What the SDK does not do#

  • It does not connect. No socket, retries, authentication, or reconnection backoff. Your provider handles all of it.
  • It does not persist. Your server decides where the document is stored between sessions.
  • It does not destroy your Y.Doc. A document you pass in outlives the component. Unmounting only clears this client's caret.
  • It does not enforce who may edit. Roles limit what each embed can do, but each client sets its own role, and there is no document locking. To enforce a role, check it on the server at the transport. The backend entry lets that server process read and edit the document it guards.