Collaborative Text Editor (CRDT)
Build a collaborative text editor such as Google Docs or Notion with Yjs, where one Actor per document relays edits and presence and saves the document.
What we’ll build:
- A
documentActor per document that keeps the merged Yjs document and relays every edit to the other editors. - Live cursors and names over the Yjs Awareness protocol, cleaned up when someone closes the tab.
- A snapshot of the document in the Actor’s KV storage, so it survives restarts.
- A
documentListActor per workspace that creates documents and lists them. - A React editor that syncs a local
Y.Docwith the Actor.
This guide uses a CRDT. Yjs merges edits in any order on any client, so the server only relays and stores updates and never has to understand them. The other approach is operational transformation (OT), where one server orders and rewrites every operation. It suits editors that need a single authoritative order, but the server has to implement a transform for every operation type and long offline sessions are hard to merge. See Collaborative Text Editor (OT).
Install
npm add rivetkit @rivetkit/react yjs y-protocols
Create the document Actor
Each document is its own document Actor, keyed by [workspaceId, documentId]:
export const document = actor({
// Track client awareness IDs per connection.
connState: {
clientIds: [] as number[],
},
events: {
sync: event<SyncEvent>(),
awareness: event<AwarenessEvent>(),
},
// Persistent metadata that survives restarts: https://rivet.dev/docs/actors/state
createState: (_c, input: DocumentInput): DocumentState => ({
title: input.title,
createdAt: input.createdAt,
updatedAt: input.createdAt,
}),
// Load Yjs state from durable KV storage.
createVars: async (c) => {
const doc = new Y.Doc();
const stored = await c.kv.get("yjs:doc", { type: "binary" });
if (stored) {
Y.applyUpdate(doc, stored);
}
const awareness = new Awareness(doc);
return { doc, awareness };
},
createStateholds only metadata:title,createdAt, andupdatedAt. It reads the title from the creation input thatdocumentListpasses in.- The live
Y.Docand itsAwarenesslive in vars, because they are class instances that cannot be saved as JSON state.createVarsrebuilds them every time the Actor starts by loading the binaryyjs:dockey from KV storage. connStategives every connection its own list of awareness client ids, which is used to clean up presence on disconnect.- The multi-part key scopes the document to its workspace, so two workspaces can never reach each other’s documents by id.
Relay Yjs updates
Clients sync through one action and one event:
actions: {
getContent: (c) => {
const update = Y.encodeStateAsUpdate(c.vars.doc);
return toNumbers(update);
},
applyUpdate: async (
c,
update: number[],
kind: UpdateKind,
clientId?: number,
) => {
if (kind === "sync") {
const bytes = toBytes(update);
Y.applyUpdate(c.vars.doc, bytes, "client");
const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
await c.kv.put("yjs:doc", snapshot);
c.state.updatedAt = Date.now();
c.broadcast("sync", { update });
return;
}
const bytes = toBytes(update);
applyAwarenessUpdate(c.vars.awareness, bytes, "client");
if (typeof clientId === "number" && c.conn) {
const knownIds = c.conn.state.clientIds;
if (!knownIds.includes(clientId)) {
knownIds.push(clientId);
}
}
c.broadcast("awareness", { update });
},
getAwareness: (c) => {
const clients = Array.from(c.vars.awareness.getStates().keys());
const update = encodeAwarenessUpdate(c.vars.awareness, clients);
return toNumbers(update);
},
},
getContentreturns the whole merged document as one Yjs update. A client calls it once on connect to catch up.applyUpdate(update, "sync")applies a client’s change to the server’sY.Docwith origin"client"and broadcasts the same small update as thesyncevent. The server never transforms or orders edits. Yjs merges are commutative, so every client ends up with the same text no matter which update arrives first.- Updates cross the action boundary as
number[], because action arguments are serialized.toBytesandtoNumbersconvert them back and forth on both sides. - The broadcast reaches the sender too. The client applies it with origin
"remote", which Yjs ignores because it already has the change, and the client’s listener skips"remote"updates, so nothing is sent twice.
applyUpdate accepts any number[] of any size, and the example client sends one action per keystroke. In production, cap the payload size, rate limit each connection, and batch edits on the client.
Share presence
Names, colors, and cursor positions travel over the Yjs Awareness protocol, through the same action with kind "awareness":
onDisconnect: (c, conn) => {
const clientIds = conn.state.clientIds;
if (clientIds.length === 0) {
return;
}
removeAwarenessStates(c.vars.awareness, clientIds, "disconnect");
const update = encodeAwarenessUpdate(c.vars.awareness, clientIds);
c.broadcast("awareness", { update: toNumbers(update) });
conn.state.clientIds = [];
},
actions: {
getContent: (c) => {
const update = Y.encodeStateAsUpdate(c.vars.doc);
return toNumbers(update);
},
applyUpdate: async (
c,
update: number[],
kind: UpdateKind,
clientId?: number,
) => {
if (kind === "sync") {
const bytes = toBytes(update);
Y.applyUpdate(c.vars.doc, bytes, "client");
const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
await c.kv.put("yjs:doc", snapshot);
c.state.updatedAt = Date.now();
c.broadcast("sync", { update });
return;
}
const bytes = toBytes(update);
applyAwarenessUpdate(c.vars.awareness, bytes, "client");
if (typeof clientId === "number" && c.conn) {
const knownIds = c.conn.state.clientIds;
if (!knownIds.includes(clientId)) {
knownIds.push(clientId);
}
}
c.broadcast("awareness", { update });
},
getAwareness: (c) => {
const clients = Array.from(c.vars.awareness.getStates().keys());
const update = encodeAwarenessUpdate(c.vars.awareness, clients);
return toNumbers(update);
},
},
applyUpdate(update, "awareness", clientId)applies the change to the server’sAwareness, remembersclientIdin that connection’sconnState, and broadcasts theawarenessevent. Presence is never saved.getAwarenessreturns everyone’s current presence, so a client that joins late sees the other cursors right away.onDisconnectremoves every awareness client the connection asserted and broadcasts the removal, so the cursor disappears even if the tab closed without saying goodbye. See Connections.
The clientId comes from the client and is trusted as is, so one connection could remove another user’s cursor by claiming its id. In production, check it against state the server owns for that connection.
Persist snapshots
Every "sync" update also saves the whole document:
getContent: (c) => {
const update = Y.encodeStateAsUpdate(c.vars.doc);
return toNumbers(update);
},
applyUpdate: async (
c,
update: number[],
kind: UpdateKind,
clientId?: number,
) => {
if (kind === "sync") {
const bytes = toBytes(update);
Y.applyUpdate(c.vars.doc, bytes, "client");
const snapshot = Y.encodeStateAsUpdate(c.vars.doc);
await c.kv.put("yjs:doc", snapshot);
c.state.updatedAt = Date.now();
c.broadcast("sync", { update });
return;
}
Y.encodeStateAsUpdateencodes the merged document as one compact update, andc.kv.putoverwrites the singleyjs:dockey with it. There is no update log, so there is nothing to replay on start and no separate compaction job. Encoding the merged state is the compaction.- The write finishes before the broadcast, so an edit that other clients have seen is already saved. If the Actor crashes or moves,
createVarsloads the last snapshot. - Every keystroke rewrites the whole document. That is simple and fine for small and medium documents.
A single KV value is limited to 128 KiB (see Limits). Once the encoded document grows past that, kv.put throws, so applyUpdate fails before the broadcast and edits are neither saved nor relayed.
For large documents or high edit rates, append each incremental update to an update log under its own key and write a merged snapshot only every N updates, then delete the log entries it covers. Start then loads the snapshot and replays the log. Only switch once the full-snapshot write is the measured bottleneck or the document nears the value limit.
Index documents per workspace
A documentList Actor per workspace creates documents and lists them. The registry starts both Actors:
export const documentList = actor({
// One coordinator per workspace that indexes document actors.
state: {
documents: [] as DocumentSummary[],
},
actions: {
createDocument: async (c, title: string) => {
const documentId = randomUUID();
const createdAt = Date.now();
const workspaceId = c.key[0] ?? "default";
const safeTitle = title.trim() || "Untitled document";
const client = c.client<typeof registry>();
const handle = await client.document.create(
[workspaceId, documentId],
{
input: { title: safeTitle, createdAt },
},
);
await handle.resolve();
const summary: DocumentSummary = {
id: documentId,
title: safeTitle,
createdAt,
updatedAt: createdAt,
};
c.state.documents.push(summary);
return summary;
},
listDocuments: (c) => c.state.documents,
deleteDocument: (c, documentId: string) => {
c.state.documents = c.state.documents.filter(
(document) => document.id !== documentId,
);
return true;
},
},
});
// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
use: { document, documentList },
});
registry.start();
- This is the coordinator pattern from Design Patterns:
documentListowns discovery, and eachdocumentActor owns one document’s realtime state. createDocumentcreates thedocumentActor explicitly withc.client<typeof registry>()and passes{ title, createdAt }as input. See Communicating Between Actors.deleteDocumentonly removes the entry from the list. ThedocumentActor and its snapshot stay behind. To delete a document for real, also destroy the Actor.
The example has no authentication. Anyone who knows a workspace id can list and open its documents, and connecting with a new workspace id creates an empty documentList. In production, authenticate connections and check that the user may open each [workspaceId, documentId]. See Authentication and Permissions.
Connect the editor
Each open document creates its own Y.Doc in the browser and sends every local change to the Actor:
const doc = new Y.Doc();
const text = doc.getText("content");
const awareness = new Awareness(doc);
docRef.current = doc;
textRef.current = text;
awarenessRef.current = awareness;
const handleDocUpdate = (update: Uint8Array, origin: unknown) => {
setContent(text.toString());
if (origin === "remote") {
return;
}
const connection = connectionRef.current;
if (!connection) {
return;
}
connection.applyUpdate(toNumbers(update), "sync");
};
Then, every time the connection opens, it catches up and listens for changes from others:
useEffect(() => {
const connection = documentActor.connection;
const doc = docRef.current;
const awareness = awarenessRef.current;
const text = textRef.current;
if (!connection || !doc || !awareness || !text) {
return;
}
let cancelled = false;
connection
.getContent()
.then((update) => {
if (cancelled) {
return;
}
Y.applyUpdate(doc, toBytes(update), "remote");
setContent(text.toString());
})
.catch(() => null);
connection
.getAwareness()
.then((update) => {
if (cancelled) {
return;
}
applyAwarenessUpdate(awareness, toBytes(update), "remote");
setPresence(buildPresence(awareness));
})
.catch(() => null);
return () => {
cancelled = true;
};
}, [documentActor.connection]);
documentActor.useEvent("sync", (event: SyncEvent) => {
const doc = docRef.current;
if (!doc) {
return;
}
Y.applyUpdate(doc, toBytes(event.update), "remote");
});
documentActor.useEvent("awareness", (event: AwarenessEvent) => {
const awareness = awarenessRef.current;
if (!awareness) {
return;
}
applyAwarenessUpdate(awareness, toBytes(event.update), "remote");
setPresence(buildPresence(awareness));
});
useActor({ name: "document", key: [workspaceId, document.id] })connects to the document’s Actor. The text lives indoc.getText("content").- Typing calls
doc.transact(..., "local"), which fireshandleDocUpdate, which sends the update withapplyUpdate. Changes from the Actor are applied with origin"remote", so they update the screen but are not sent back. - On connect and reconnect, the client applies
getContentandgetAwarenessbefore thesyncandawarenessevents take over. The awareness listener sends local cursor moves the same way as edits. - Edits made while the connection is down stay in the local
Y.Docand are not sent on reconnect. To keep them, sendY.encodeStateAsUpdate(doc)after the catch-up, and Yjs merges whatever the Actor is missing.
The example edits a plain <textarea> and replaces the whole text on every change, so two people typing at the same moment can end up with both versions of the text. A real editor should use a Yjs binding that applies small edits, such as y-prosemirror, y-codemirror, or y-monaco. The Actor does not change.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/collaborative-document
pnpm install
pnpm dev
Open http://localhost:5173 in two tabs and change the Name in one of them. Click Create in the first tab to make a document. The list does not update live, so reload the second tab and click Open on the new document. Type in either tab and the text shows up in both, with the other person’s cursor marked by their name and listed under Active collaborators.
Next steps
- Collaborative Text Editor (OT): the same editor with operational transformation.
- Live Cursors and Presence: cursors and presence without a document.
- Authentication: sign users in before they open a document.