Skip to main content
Collaboration is a core pillar of this SDK, not a mode you switch on. Every document is a Yjs CRDT from its first keystroke — the single-user editor is just a room with one person in it. That design buys you three things most document SDKs cannot offer together:
  • Real-time multi-user editing over a transport you own — carets, presence, tracked changes, and comments are all collaborative out of the box, with your authentication and your infrastructure in the path, not ours.
  • Word fidelity that survives collaboration — concurrent edits merge as CRDT operations, and the result still round-trips to .docx with native tracked changes and comments intact.
  • A server that participates in the same room@reviseio/sdk/backend runs the same converters and the same semantic tools under Node, so your backend can create documents before anyone opens them, edit live rooms headlessly, and propose tracked changes your users review in the browser. No other DOCX SDK offers this half at all.
The SDK never opens a socket: you own the transport, its authentication, and its lifecycle. Hand us the provider, and the document becomes collaborative.
That is the whole integration. The provider’s document is edited in place, its awareness carries carets and presence, and the provider object itself identifies 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. Install it yourself — it is a peer dependency, not something we bundle:
If a bundler hands the two sides different copies, 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 optimiser:
vite.config.ts
Webpack and Next.js resolve a single copy on their own provided there is only one yjs in your lockfile; npm ls yjs is the check.

Let the editor build the document

The editor reserves eight top-level keys in the Yjs document — content, chrome, notes, comments, annotations, metadata, sdt-wrappers, paragraphs — and relies on specific document settings. So the recommended form hands you a document and an awareness instance and asks you only to connect them:
Return the provider: that is what lets the editor recognise a peer’s edit. Your application still owns the connection, its authentication, and its lifetime; the editor only builds the document it has to be able to read.

Bringing your own document

Some transports create the document themselves, so both of these still work:
A document you supply is validated before the editor writes to it. An empty one is seeded; one that already holds a Revise document is joined; one whose reserved keys hold something else is refused outright:
That last case is the one to watch if you keep a single Yjs document per room for your whole application — comments and metadata are names you may well have used first. Give the editor its own document, or keep your data in a separate one. The explicit path is otherwise the escape hatch — for a transport we do not recognise, a custom sync layer, or a setup where the document and the connection are separate objects:
With the explicit path you must pass remoteOrigin: the value your transport gives Y.applyUpdate as its transaction origin. Without it a peer’s edit is indistinguishable from a local one, which breaks cursor restoration and lets Ctrl+Z undo someone else’s typing. A transport that applies updates with a null origin cannot be used directly — wrap it so it tags its own transactions. Passing provider handles this for you, because virtually every provider passes itself.

Who seeds the document

The first participant establishes the content; everyone else joins it. That is what seed controls:
  • "if-empty" (default) — if the shared document has no content, the docx source is written into it. If it already has content, the source is ignored and you join what is there.
  • "never" — always join. docx becomes unnecessary, and a document input with no source is valid:
Seeding happens after the first sync, never before. A participant who seeded a room they had not yet received the state of would duplicate the entire document.

Telling us when you are synced

synced is how you say the provider has received the room’s initial state. Until it turns true the editor holds the document closed rather than showing an empty page someone can type into:
Later disconnections do not close the document. Yjs merges offline edits when the transport returns, so a dropped connection is a presence problem, not an editing one. If your transport gives you no sync signal, omit synced and the editor opens immediately — accepting the duplicate-seed risk on a brand-new room.

Who is in the room

collaboration.getState() normalises awareness into a roster, using the same colours the carets are drawn in:
It fires on join, leave, caret movement, and sync-state changes — enough to render a facepile that stays honest. A participant with no currentUser does not appear, because there is nothing to show.

Presence and cursors

Remote carets render automatically when awareness is available, labelled and coloured per participant. Everything a peer sees comes from currentUser:
name labels the caret and image is drawn on its flag — any URL the browser can load, including a data: URI, so an app with no avatar CDN can still give each participant a face. color is optional. Without it, a stable colour is derived from the user id, so the same person is the same colour for everyone with no coordination. Set it when the colours are yours to choose — brand palettes, or matching an avatar you already show elsewhere. A participant with no currentUser has no caret drawn for others: there is nothing to label it with. Pass one for anybody who should be visible.
currentUser is also what stamps authorship on tracked changes. Without it, suggestions export to Word as reviewer “Anonymous”. Set it once and both problems go away.

Your server is a participant

Everything above treats the server as plumbing: it moves updates between browsers. @reviseio/sdk/backend makes it a peer. Because the room is a Yjs document and the backend entry runs the same converters and mutation engine as the editor, anything your server does to the Y.Doc reaches every open browser as an ordinary update — and everything a browser does reaches your server the same way. The full surface is in the backend reference; this section is the workflows.

Seeding from your own server

The browser is not the only place that can create a document. Your server can turn a file your systems already hold into the room itself:
Clients then pass seed: "never" and no source file: everyone joins a document that already exists. This is the difference between a product where the first browser to open a document imports it — a race when two people click at once — and one where documents exist before anyone opens them. jsdom is a peer dependency of that entry point; the converters parse XML and HTML with DOM APIs. A full worked example, with a syncing and persisting server, is in examples/collaborative-docx.

Editing the live room headlessly

To mutate a room on the server, bind a document session to it. The session exposes the same semantic tools the browser does — read, search, measure, mutate, comment — transacting through the same Yjs-backed mutation engine, so peers receive ordinary updates and remote carets never miss a beat:
Sessions default to suggesting mode: a service proposes tracked changes unless it explicitly opts into direct edits (mode: "editing", or a per-call directMode). That default is what makes an unattended backend workflow safe to point at a shared document — nothing is applied until a person accepts it.

One review loop, both surfaces

Server and browser speak the same tool contract — tools.execute() returns the same ReviseToolResult envelope, mutations report the same suggestionIds, and accept/reject returns the same ReviseSuggestionDecision on either side. So a review pipeline composes across the stack: the server proposes, the person in the browser decides.
The reverse composes too: a human suggests in the browser, and a nightly job inspects session.listSuggestions() — every pending record carries authorship metadata — and settles its own agent’s proposals while leaving human ones alone. See tracked changes for the review UI half.

Working without a live transport

A server does not need a socket to participate. For request/response integrations — an API endpoint, a queue worker — the client posts its document as one encoded update, the server works on a decoded copy, and answers with only the delta — which merges into the live document like any peer’s edit, even if the user kept typing during the round-trip:
Stateless on the server, no transport dependency, and the same review loop as above — the response carries the created suggestionIds.

What the SDK does not do

  • It does not connect. No socket, no retries, no auth, no reconnection backoff. Your provider owns all of it.
  • It does not persist. Where the document lives between sessions is your server’s business.
  • It does not destroy your Y.Doc. A document you passed in outlives the component; unmounting only clears this client’s caret.
  • It does not arbitrate who may edit. Per-participant roles constrain what each embed can do, but they are set by each client, and there is no document locking. If a role must be authoritative, your server has to enforce it on the transport — which the backend entry makes practical, since the enforcing process can read and edit the document it is guarding.