Collaborative Text Editor
IMPORTANT: Before doing anything, you MUST read in this skill's directory. It contains essential guidance on debugging, error handling, state management, deployment, and project setup. Those rules and patterns apply to all RivetKit work. Everything below assumes you have already read and understood it.
Working Examples
If you need a reference implementation, read the raw working example code in these templates:
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 Case | Starter Code | Common Examples |
|---|
| Shared document editing | GitHub | 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
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 . The document actor relays incremental Yjs updates as broadcast events and keeps a server-side merged copy in vars. |
| Persistence | Full merged Yjs snapshot overwritten in one binary actor KV key () on every sync update. Document metadata lives in JSON state. |
| Queues | None. The example is purely actions plus broadcast events. |
| Presence | Yjs Awareness relayed through the same action. Per-connection 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
-
Key:
document[workspaceId, documentId]
-
Responsibility: Applies incoming sync and awareness updates to a server-side
and
, persists the merged Yjs snapshot to actor KV, and broadcasts updates to all connected collaborators.
-
Actions
-
Queues
-
State
- JSON metadata only: , ,
- Binary KV key holding the full merged Yjs snapshot
- Ephemeral vars: the live and , created in and rehydrated from KV on actor start
- Per-connection : 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
-
Queues
-
State
- JSON
- array of entries (, , , )
The coordinator's
generates a UUID, then explicitly creates the document actor with
c.client<typeof registry>()
and passes
as creation
input, which the document actor's
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
byte arrays and are converted back to
on each side.
| Kind | Server Applies To | Persists | Broadcasts |
|---|
| via with origin | Full merged snapshot to KV key , then bumps | event carrying the incremental update |
| via with origin | Nothing. Presence is ephemeral. | 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
.
Yjs origin tags are the echo guards that keep the relay loop-free:
| Origin Tag | Set Where | Effect |
|---|
| Client edits inside doc.transact(..., "local")
| The client's update listener fires and sends to the actor. |
| Server applying an incoming update to its or | Marks the change as client-originated on the server copy. |
| Client applying broadcast events or initial sync data | Update listeners early-return on , so a client never re-sends its own echo. |
On connect or reconnect, the client calls
and
, then applies both results to its local
and
with origin
. After that, every change flows through
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 and fields. The awareness update listener encodes the change and sends applyUpdate(update, "awareness", awareness.clientID)
.
- The actor records each asserted in that connection's , applies the update to the server-side , and broadcasts the event to all peers. See Connections for per-connection state.
- reads the connection's , calls on the server-side , 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
update, the actor re-encodes the entire merged document with
and overwrites the single binary KV key
. There is no append-only update log and no separate compaction job. Compaction is implicit because
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 rehydrates the document on actor start. |
| Crash safety | The last completed 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 implicitly getOrCreates, connecting with a nonexistent workspace ID silently creates a blank 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 : Update payloads are unvalidated 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 argument to is client-supplied and trusted as-is. Derive or verify presence identity from connection-scoped server state instead.
- Destroy actors and KV on delete: 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.
Reference Map
Actors
- Access Control
- Actions
- Actor Keys
- Actor Scheduling
- Actor Statuses
- AI and User-Generated Rivet Actors
- Authentication
- Communicating Between Actors
- Connections
- Custom Inspector Tabs
- Debugging
- Design Patterns
- Destroying Actors
- Errors
- Fetch and WebSocket Handler
- Helper Types
- Icons & Names
- Input Parameters
- Lifecycle
- Limits
- Low-Level HTTP Request Handler
- Low-Level KV Storage
- Low-Level WebSocket Handler
- Metadata
- Next.js Quickstart
- Node.js & Bun Quickstart
- Queues & Run Loops
- React Quickstart
- Realtime
- Rust Quickstart (Preview)
- Sandbox Actor
- Scaling & Concurrency
- Sharing and Joining State
- SQLite
- SQLite + Drizzle
- State & Storage
- Testing
- Troubleshooting
- Types
- Vanilla HTTP API
- Versions & Upgrades
- Workflows
Agent Os
- Agent-to-Agent Communication
- agentOS vs Sandbox
- Authentication
- Benchmarks
- Configuration
- Core Package
- Cron Jobs
- Deployment
- Embedded LLM Gateway
- Events
- Filesystem
- Limitations
- LLM Credentials
- Multiplayer
- Networking & Previews
- Overview
- Permissions
- Persistence & Sleep
- Pi
- Processes & Shell
- Queues
- Quickstart
- Sandbox Mounting
- Security & Auth
- Security Model
- Sessions
- Software
- SQLite
- System Prompt
- Tools
- Webhooks
- Workflow Automation
Clients
- Node.js & Bun
- React
- Swift
- SwiftUI
Connect
- Deploy To Amazon Web Services Lambda
- Deploying to AWS ECS
- Deploying to Cloudflare Workers
- Deploying to Freestyle
- Deploying to Google Cloud Run
- Deploying to Hetzner
- Deploying to Kubernetes
- Deploying to Railway
- Deploying to Rivet Compute
- Deploying to Supabase Functions
- Deploying to Vercel
- Deploying to VMs & Bare Metal
Cookbook
- AI Agent
- AI Agent Workspaces
- Chat Room
- Collaborative Text Editor
- Cron Jobs and Scheduled Tasks
- Database per Tenant
- Deploying Rivet in a VPC or Air-Gapped Network
- Live Cursors and Presence
- Multiplayer Game
General
- Actor Configuration
- Architecture
- Cross-Origin Resource Sharing
- Documentation for LLMs & AI
- Edge Networking
- Endpoints
- Environment Variables
- HTTP Server
- Logging
- Pool Configuration
- Production Checklist
- Registry Configuration
- Runtime Modes
Self Hosting
- Configuration
- Docker Compose
- Docker Container
- File System
- FoundationDB (Enterprise)
- Installing Rivet Engine
- Kubernetes
- Multi-Region
- PostgreSQL
- Production Checklist
- Railway Deployment
- Render Deployment
- TLS & Certificates