# Collaborative Text Editor > Source: `src/content/cookbook/collaborative-text-editor.mdx` > Canonical URL: https://rivet.dev/cookbook/collaborative-text-editor > Description: Build a collaborative text editor backend with Yjs CRDTs and Rivet Actors: per-document actors relay sync and awareness updates and persist snapshots. --- 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](https://github.com/rivet-dev/rivet/tree/main/examples/collaborative-document) and adapt it to your editor. It ships a React frontend with a plain textarea, remote cursor overlays, and a workspace document index. | Use Case | Starter Code | Common Examples | | --- | --- | --- | | Shared document editing | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/collaborative-document) | Notion-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. | Dimension | CRDT (Yjs) | Operational Transformation | | --- | --- | --- | | Conflict resolution model | Commutative 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 support | Strong. Clients keep editing locally and merge buffered updates on reconnect. | Weak. Long-lived divergence makes transformation chains complex and fragile. | | Server role | Relay 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 maturity | Yjs 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 | Topic | Summary | | --- | --- | | Topology | One `document[workspaceId, documentId]` actor per document plus one `documentList[workspaceId]` coordinator per workspace. | | Sync model | Each client holds a local `Y.Doc`. The document actor relays incremental Yjs updates as broadcast [events](/docs/actors/events) and keeps a server-side merged copy in vars. | | Persistence | Full merged Yjs snapshot overwritten in one binary [actor KV](/docs/actors/kv) key (`yjs:doc`) on every sync update. Document metadata lives in JSON [state](/docs/actors/state). | | Queues | None. The example is purely [actions](/docs/actors/actions) plus broadcast events. | | Presence | Yjs 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](/docs/actors/design-patterns): the coordinator owns discovery and creation, and each document actor owns one document's realtime state. Multi-part [keys](/docs/actors/keys) scope both actors to a workspace. **Actors** - **Key**: `document[workspaceId, documentId]` - **Responsibility**: Applies incoming sync and awareness updates to a server-side `Y.Doc` and `Awareness`, persists the merged Yjs snapshot to actor KV, and broadcasts updates to all connected collaborators. - **Actions** - `getContent` - `applyUpdate` - `getAwareness` - **Queues** - None - **State** - JSON metadata only: `title`, `createdAt`, `updatedAt` - Binary KV key `yjs:doc` holding the full merged Yjs snapshot - Ephemeral vars: the live `Y.Doc` and `Awareness`, created in `createVars` and rehydrated from KV on actor start - Per-connection `connState`: `clientIds` of awareness clients asserted by that connection - **Key**: `documentList[workspaceId]` - **Responsibility**: Coordinator for one workspace. Creates document actors through the actor-to-actor client and maintains the index of document summaries. - **Actions** - `createDocument` - `listDocuments` - `deleteDocument` - **Queues** - None - **State** - JSON - `documents` array of `DocumentSummary` entries (`id`, `title`, `createdAt`, `updatedAt`) The coordinator's `createDocument` generates a UUID, then explicitly creates the document actor with `c.client()` and passes `{ title, createdAt }` as creation [input](/docs/actors/input), which the document actor's `createState` consumes. See [Communicating Between Actors](/docs/actors/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. | Kind | Server Applies To | Persists | Broadcasts | | --- | --- | --- | --- | | `"sync"` | `c.vars.doc` via `Y.applyUpdate` with origin `"client"` | Full merged snapshot to KV key `yjs:doc`, then bumps `updatedAt` | `sync` 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 Tag | Set Where | Effect | | --- | --- | --- | | `"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 `Awareness` | Marks the change as client-originated on the server copy. | | `"remote"` | Client applying broadcast events or initial sync data | Update 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](/docs/actors/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](/docs/actors/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. | Property | Full-Snapshot Overwrite (the example) | | --- | --- | | Write cost | One full-document KV write per sync update, so every keystroke rewrites the whole blob. | | Read cost | One binary KV read in `createVars` rehydrates the document on actor start. | | Crash safety | The last completed `applyUpdate` is durable. No log replay needed. | | Sweet spot | Small 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 ```mermaid 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](/docs/actors/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. _Source doc path: /cookbook/collaborative-text-editor_