Skip to main content
Realtime

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.

See the full example on GitHub. View on GitHub
Editor Atypes an editEditor Bsees the editdocumentListworkspace indexdocumentlive Y.DocDocument SQLitesnapshot columncreateDocument1create2applyUpdate3save snapshot4sync and awareness events5One per documentRivet ActorCallReply

What we’ll build:

  • A document Actor 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 SQLite database, so it survives restarts.
  • A documentList Actor per workspace that creates documents and lists them.
  • A React editor that syncs a local Y.Doc with 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({
	events: {
		sync: event<SyncEvent>(),
		awareness: event<AwarenessEvent>(),
	},

	// Metadata and the Yjs snapshot survive restarts: https://rivet.dev/docs/actors/sqlite
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS document (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					title TEXT NOT NULL,
					snapshot BLOB,
					created_at INTEGER NOT NULL,
					updated_at INTEGER NOT NULL
				)
			`);
		},
	}),

	// Save the metadata from the creation input once, when the Actor is created.
	onCreate: async (c, input: DocumentInput) => {
		await c.db.execute(
			"INSERT INTO document (id, title, created_at, updated_at) VALUES (1, ?, ?, ?)",
			input.title,
			input.createdAt,
			input.createdAt,
		);
	},

	// Rebuild the live Yjs document from the last saved snapshot.
	createVars: async (c) => {
		const doc = new Y.Doc();
		const [row] = await c.db.execute<{ snapshot: Uint8Array | null }>(
			"SELECT snapshot FROM document WHERE id = 1",
		);
		if (row?.snapshot) {
			Y.applyUpdate(doc, row.snapshot);
		}
		const awareness = new Awareness(doc);
		// Track client awareness IDs per connection, keyed by connection ID.
		const clientIdsByConn = new Map<string, number[]>();
		return { doc, awareness, clientIdsByConn };
	},
  • The Actor’s SQLite database holds one document row with the title, created_at, updated_at, and a snapshot of the Yjs document. onCreate runs once and saves the title from the creation input that documentList passes in.
  • The live Y.Doc and its Awareness live in vars, because they are live class instances that are never saved. createVars rebuilds them every time the Actor starts by loading the snapshot column.
  • clientIdsByConn in vars maps each connection’s ID to its 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.db.execute(
				"UPDATE document SET snapshot = ?, updated_at = ? WHERE id = 1",
				snapshot,
				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.vars.clientIdsByConn.get(c.conn.id) ?? [];
			if (!knownIds.includes(clientId)) {
				knownIds.push(clientId);
			}
			c.vars.clientIdsByConn.set(c.conn.id, knownIds);
		}
		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);
	},
},
  • getContent returns 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’s Y.Doc with origin "client" and broadcasts the same small update as the sync event. 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. toBytes and toNumbers convert 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 = c.vars.clientIdsByConn.get(conn.id) ?? [];
	c.vars.clientIdsByConn.delete(conn.id);
	if (clientIds.length === 0) {
		return;
	}
	removeAwarenessStates(c.vars.awareness, clientIds, "disconnect");
	const update = encodeAwarenessUpdate(c.vars.awareness, clientIds);
	c.broadcast("awareness", { update: toNumbers(update) });
},

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.db.execute(
				"UPDATE document SET snapshot = ?, updated_at = ? WHERE id = 1",
				snapshot,
				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.vars.clientIdsByConn.get(c.conn.id) ?? [];
			if (!knownIds.includes(clientId)) {
				knownIds.push(clientId);
			}
			c.vars.clientIdsByConn.set(c.conn.id, knownIds);
		}
		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’s Awareness, remembers clientId under that connection’s ID in clientIdsByConn, and broadcasts the awareness event. Presence is never saved.
  • getAwareness returns everyone’s current presence, so a client that joins late sees the other cursors right away.
  • onDisconnect removes every awareness client the connection asserted, drops the connection’s entry from clientIdsByConn, 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 data 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.db.execute(
			"UPDATE document SET snapshot = ?, updated_at = ? WHERE id = 1",
			snapshot,
			Date.now(),
		);
		c.broadcast("sync", { update });
		return;
	}
  • Y.encodeStateAsUpdate encodes the merged document as one compact update, and a single UPDATE stores it in the snapshot column along with updated_at. 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, createVars loads the last snapshot.
  • Every keystroke rewrites the whole document. That is simple and fine for small and medium documents.

getContent returns the whole document in one action response, and responses are limited to 1 MiB by default (see Limits). The number[] encoding is larger than the raw bytes, so once a document approaches that size, new editors can no longer catch up.

For large documents or high edit rates, append each incremental update to an updates table and write a merged snapshot only every N updates, then delete the rows it covers. Start then loads the snapshot and replays the remaining updates. Only switch once the full-snapshot write is the measured bottleneck.

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 in SQLite.
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS documents (
					id TEXT PRIMARY KEY,
					title TEXT NOT NULL,
					created_at INTEGER NOT NULL,
					updated_at INTEGER NOT NULL
				)
			`);
		},
	}),

	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,
			};
			await c.db.execute(
				"INSERT INTO documents (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
				summary.id,
				summary.title,
				summary.createdAt,
				summary.updatedAt,
			);
			return summary;
		},
		listDocuments: async (c): Promise<DocumentSummary[]> => {
			return (await c.db.execute(
				"SELECT id, title, created_at AS createdAt, updated_at AS updatedAt FROM documents ORDER BY created_at",
			)) as DocumentSummary[];
		},
		deleteDocument: async (c, documentId: string) => {
			await c.db.execute("DELETE FROM documents WHERE 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: documentList owns discovery, and each document Actor owns one document’s realtime state.
  • documentList stores each document’s summary in its own SQLite table.
  • createDocument creates the document Actor explicitly with c.client<typeof registry>() and passes { title, createdAt } as input. See Communicating Between Actors.
  • deleteDocument only removes the entry from the list. The document Actor 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 in doc.getText("content").
  • Typing calls doc.transact(..., "local"), which fires handleDocUpdate, which sends the update with applyUpdate. 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 getContent and getAwareness before the sync and awareness events 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.Doc and are not sent on reconnect. To keep them, send Y.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