Skip to main content
← Back to Cookbooks

Collaborative Text Editor

Build a collaborative text editor backend with Yjs CRDTs and Rivet Actors: a per-document actor relays CRDT document sync and awareness updates to every collaborator, persists merged Yjs snapshots in actor KV, and a coordinator actor indexes documents per workspace.

Working examples

If you need a reference implementation, read the raw working example code in these templates.

Install skill for coding agents

npx skills add rivet-dev/skills -s collaborative-text-editor

Supports Claude Code, Codex, Cursor, and other AI coding assistants.

Patterns for building a Yjs server on RivetKit: CRDT document sync, presence and cursors, and snapshot persistence, with one Rivet Actor per document acting as a relay.

Starter Code

Start with the working example on GitHub and adapt it to your editor. It ships a React frontend with a plain textarea, remote cursor overlays, and a workspace document index.

Use CaseStarter CodeCommon Examples
Shared document editingGitHubNotion-style docs, shared notes, pair-writing tools, form co-editing

CRDT vs OT

Two families of algorithms solve concurrent text editing. The choice decides what your server has to do.

DimensionCRDT (Yjs)Operational Transformation
Conflict resolution modelCommutative merges. Updates apply in any order on any peer and converge to the same result.Server transforms each operation against every concurrent operation. Correctness depends on a central sequencer.
Offline supportStrong. Clients keep editing locally and merge buffered updates on reconnect.Weak. Long-lived divergence makes transformation chains complex and fragile.
Server roleRelay plus persistence. The server applies opaque updates and rebroadcasts them. It never needs to understand document semantics.Authoritative transformer. The server must implement transformation logic for every operation type.
Library maturityYjs is mature and widely deployed, with bindings for ProseMirror, CodeMirror, Monaco, and others.Production-grade implementations are mostly proprietary (Google Docs) or aging (ShareDB).

The example uses Yjs because CRDTs let the server stay a relay-style Rivet Actor. The actor applies each incoming update to a server-side Y.Doc so it can persist the merged state and serve late joiners, but it never transforms operations or arbitrates conflicts. Ordering does not matter because Yjs merges are commutative.

Document Actor Model

TopicSummary
TopologyOne document[workspaceId, documentId] actor per document plus one documentList[workspaceId] coordinator per workspace.
Sync modelEach client holds a local Y.Doc. The document actor relays incremental Yjs updates as broadcast events and keeps a server-side merged copy in vars.
PersistenceFull merged Yjs snapshot overwritten in one binary actor KV key (yjs:doc) on every sync update. Document metadata lives in JSON state.
QueuesNone. The example is purely actions plus broadcast events.
PresenceYjs Awareness relayed through the same applyUpdate action. Per-connection connState tracks asserted awareness clientIds for disconnect cleanup.

The two-actor split follows the coordinator pattern from Design Patterns: the coordinator owns discovery and creation, and each document actor owns one document’s realtime state. Multi-part keys scope both actors to a workspace.

Actors

The coordinator’s createDocument generates a UUID, then explicitly creates the document actor with c.client<typeof registry>() and passes { title, createdAt } as creation input, which the document actor’s createState consumes. See Communicating Between Actors for the actor-to-actor client.

Update Relay

A single applyUpdate(update, kind, clientId?) action handles both update kinds. Updates cross the action boundary as number[] byte arrays and are converted back to Uint8Array on each side.

KindServer Applies ToPersistsBroadcasts
"sync"c.vars.doc via Y.applyUpdate with origin "client"Full merged snapshot to KV key yjs:doc, then bumps updatedAtsync event carrying the incremental update
"awareness"c.vars.awareness via applyAwarenessUpdate with origin "client"Nothing. Presence is ephemeral.awareness event carrying the update

Note the asymmetry on the sync branch: the broadcast carries only the small incremental update, while the KV write stores the full merged document re-encoded with Y.encodeStateAsUpdate.

Yjs origin tags are the echo guards that keep the relay loop-free:

Origin TagSet WhereEffect
"local"Client edits inside doc.transact(..., "local")The client’s update listener fires and sends applyUpdate to the actor.
"client"Server applying an incoming update to its Y.Doc or AwarenessMarks the change as client-originated on the server copy.
"remote"Client applying broadcast events or initial sync dataUpdate listeners early-return on "remote", so a client never re-sends its own echo.

On connect or reconnect, the client calls getContent and getAwareness, then applies both results to its local Y.Doc and Awareness with origin "remote". After that, every change flows through applyUpdate and the broadcast events.

Awareness And Presence

Presence (user names, colors, cursor positions) rides on the Yjs Awareness protocol instead of actor state:

  • Clients set presence with awareness.setLocalStateField for the user and cursor fields. The awareness update listener encodes the change and sends applyUpdate(update, "awareness", awareness.clientID).
  • The actor records each asserted clientId in that connection’s connState.clientIds, applies the update to the server-side Awareness, and broadcasts the awareness event to all peers. See Connections for per-connection state.
  • onDisconnect reads the connection’s clientIds, calls removeAwarenessStates on the server-side Awareness, and broadcasts the encoded removal so every remaining client drops the departed user’s cursor. See Lifecycle for the hook.

Because the actor tracks which awareness clientIds belong to which connection, presence cleanup is automatic on disconnect with no client cooperation required.

Persistence And Compaction

The example persists with a full-snapshot overwrite: on every "sync" update, the actor re-encodes the entire merged document with Y.encodeStateAsUpdate and overwrites the single binary KV key yjs:doc. There is no append-only update log and no separate compaction job. Compaction is implicit because Y.encodeStateAsUpdate emits one compact merged representation of the document, so Yjs merge semantics keep the stored blob compact on their own.

PropertyFull-Snapshot Overwrite (the example)
Write costOne full-document KV write per sync update, so every keystroke rewrites the whole blob.
Read costOne binary KV read in createVars rehydrates the document on actor start.
Crash safetyThe last completed applyUpdate is durable. No log replay needed.
Sweet spotSmall to medium documents where simplicity beats write amplification.

Recommended extension (not in the example): for large documents or very high edit rates, switch to appending incremental updates to a KV update log and writing a merged snapshot only periodically (for example every N updates). Boot becomes snapshot plus log replay, and the snapshot write becomes the explicit compaction step that truncates the log. Adopt this only when full-snapshot writes become the measured bottleneck or the blob approaches KV value size limits.

Lifecycle

sequenceDiagram participant A as Client A participant B as Client B participant DL as documentList participant D as document A->>DL: listDocuments() A->>DL: createDocument(title) DL->>D: create([workspaceId, documentId], input) DL-->>A: DocumentSummary A->>D: connect B->>D: connect Note over D: createVars rehydrates Y.Doc from KV "yjs:doc" A->>D: getContent() + getAwareness() D-->>A: encoded doc + awareness state Note over A: local edit with origin "local" A->>D: applyUpdate(update, "sync") Note over D: apply with origin "client", overwrite KV snapshot D-->>B: sync event (incremental update) Note over B: apply with origin "remote", no echo A->>D: applyUpdate(update, "awareness", clientId) D-->>B: awareness event B-->>D: disconnect Note over D: onDisconnect removes B's awareness clientIds D-->>A: awareness event (removal)

Security Checklist

The example ships with no authentication or authorization. Harden it with this baseline before production. None of these are implemented in the example.

  • Authenticate before connect: Anyone who knows or guesses a workspace ID can connect, and because useActor implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank documentList coordinator. Add connection auth so unauthenticated clients never reach an actor. See Authentication.
  • Per-document access control: Validate that the authenticated user is allowed to access the specific [workspaceId, documentId] key, not just any document.
  • Cap and rate limit applyUpdate: Update payloads are unvalidated number[] arrays with no size limit, and the example client sends one action per keystroke and per cursor move with zero throttling. Enforce payload size caps and per-connection rate limits on the server, and debounce on the client.
  • Do not trust client-asserted awareness clientIds: The clientId argument to applyUpdate is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead.
  • Destroy actors and KV on delete: deleteDocument only filters the entry out of the coordinator’s index. The document actor and its KV snapshot are orphaned. On delete, also destroy the document actor and its storage, with a permission check on who may delete.