# Build a Real-Time Collaborative Editor with Y.js, WebSockets, and React
Real-time collaboration has become a must-have feature in modern web applications. From Google Docs to Figma, users expect to edit content simultaneously without conflicts. In this tutorial, you'll build a fully functional collaborative text editor from scratch using Y.js (a CRDT library), WebSockets for real-time sync, and React with TipTap for a rich-text editing experience.
What You'll Build
- A rich-text editor that multiple users can edit simultaneously
- Real-time cursor and selection sharing (awareness)
- A WebSocket server that syncs document state across clients
- Offline support with automatic reconnection
- Document persistence to the file system
- Node.js 18+ and npm/pnpm
- Basic knowledge of React, TypeScript, and WebSockets
- Familiarity with rich-text editors is helpful but not required
- Y.Text — collaborative text with formatting attributes
- Y.Array — shared ordered lists
- Y.Map — shared key-value maps
- Y.XmlFragment — shared XML/HTML trees (used by TipTap/ProseMirror)
Prerequisites
Understanding CRDTs and Y.js
CRDTs (Conflict-free Replicated Data Types) are data structures that can be independently updated on different replicas and always merge to a consistent state — without coordination. Y.js is the most popular CRDT implementation for JavaScript, offering:
Unlike Operational Transformation (OT) used by Google Docs, CRDTs don't need a central server to resolve conflicts. Every peer converges to the same state automatically.
Step 1: Project Setup
Create the project with a monorepo structure:
mkdir collab-editor && cd collab-editor
mkdir client server
# Initialize the React client
cd client
npm create vite@latest . -- --template react-ts
npm install
# Install collaborative editing dependencies
npm install yjs y-websocket @tiptap/react @tiptap/starter-kit \
@tiptap/extension-collaboration @tiptap/extension-collaboration-cursor \
@tiptap/pm
# Initialize the server
cd ../server
npm init -y
npm install ws yjs y-protocols lib0
npm install -D typescript @types/ws tsx
Add a tsconfig.json for the server:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
Step 2: Build the WebSocket Server
Create server/src/index.ts:
import { WebSocketServer, WebSocket } from 'ws';
import * as Y from 'yjs';
import { encoding, decoding } from 'lib0';
import fs from 'fs';
import path from 'path';
const PORT = 4444;
const PERSISTENCE_DIR = './docs';
// Message types matching y-websocket protocol
const messageSync = 0;
const messageAwareness = 1;
interface Room {
doc: Y.Doc;
clients: Set<WebSocket>;
awareness: Map<number, any>;
}
const rooms = new Map<string, Room>();
// Ensure persistence directory exists
if (!fs.existsSync(PERSISTENCE_DIR)) {
fs.mkdirSync(PERSISTENCE_DIR, { recursive: true });
}
function getRoom(roomName: string): Room {
if (rooms.has(roomName)) return rooms.get(roomName)!;
const doc = new Y.Doc();
// Load persisted state if it exists
const filePath = path.join(PERSISTENCE_DIR, ${roomName}.yjs);
if (fs.existsSync(filePath)) {
const persistedState = fs.readFileSync(filePath);
Y.applyUpdate(doc, new Uint8Array(persistedState));
console.log(Loaded persisted document: ${roomName});
}
// Persist on every update (debounced in production)
let persistTimeout: NodeJS.Timeout | null = null;
doc.on('update', () => {
if (persistTimeout) clearTimeout(persistTimeout);
persistTimeout = setTimeout(() => {
const state = Y.encodeStateAsUpdate(doc);
fs.writeFileSync(filePath, Buffer.from(state));
console.log(Persisted document: ${roomName});
}, 1000); // Debounce 1 second
});
const room: Room = {
doc,
clients: new Set(),
awareness: new Map(),
};
rooms.set(roomName, room);
return room;
}
function broadcastToRoom(
room: Room,
message: Uint8Array,
exclude?: WebSocket
) {
room.clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
const wss = new WebSocketServer({ port: PORT });
wss.on('connection', (ws, req) => {
const roomName = req.url?.slice(1) || 'default';
const room = getRoom(roomName);
room.clients.add(ws);
console.log(
Client connected to room "${roomName}" (${room.clients.size} clients)
);
// Send current document state as sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
const sv = Y.encodeStateVector(room.doc);
encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdate(room.doc, sv));
ws.send(encoding.toUint8Array(encoder));
// Send existing awareness states
room.awareness.forEach((state, clientId) => {
const awarenessEncoder = encoding.createEncoder();
encoding.writeVarUint(awarenessEncoder, messageAwareness);
encoding.writeVarString(
awarenessEncoder,
JSON.stringify({ clientId, state })
);
ws.send(encoding.toUint8Array(awarenessEncoder));
});
ws.on('message', (data: Buffer) => {
const message = new Uint8Array(data);
const decoder = decoding.createDecoder(message);
const msgType = decoding.readVarUint(decoder);
switch (msgType) {
case messageSync: {
const update = decoding.readVarUint8Array(decoder);
Y.applyUpdate(room.doc, update);
// Broadcast update to other clients
broadcastToRoom(room, message, ws);
break;
}
case messageAwareness: {
const awarenessStr = decoding.readVarString(decoder);
const { clientId, state } = JSON.parse(awarenessStr);
room.awareness.set(clientId, state);
// Broadcast awareness to other clients
broadcastToRoom(room, message, ws);
break;
}
}
});
ws.on('close', () => {
room.clients.delete(ws);
console.log(
Client disconnected from room "${roomName}" (${room.clients.size} clients)
);
// Clean up empty rooms after a delay
if (room.clients.size === 0) {
setTimeout(() => {
if (room.clients.size === 0) {
rooms.delete(roomName);
console.log(Room "${roomName}" cleaned up);
}
}, 30000);
}
});
});
console.log(WebSocket server running on ws://localhost:${PORT});
This server manages document rooms, persists state to disk, and broadcasts updates between clients.
Step 3: Using y-websocket Provider (Simplified Approach)
For production, use the built-in y-websocket provider instead of the custom server above. It handles all the sync protocol details:
# Start the y-websocket server (built-in)
npx y-websocket --port 4444
Or create a custom server with persistence using y-websocket/bin/utils:
import { setupWSConnection } from 'y-websocket/bin/utils';
import { WebSocketServer } from 'ws';
import http from 'http';
const server = http.createServer();
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
setupWSConnection(ws, req, {
docName: req.url?.slice(1) || 'default',
gc: true,
});
});
server.listen(4444, () => {
console.log('y-websocket server running on port 4444');
});
Step 4: Build the Collaborative Editor Component
Create client/src/CollaborativeEditor.tsx:
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react';}import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { useEffect, useMemo, useState } from 'react';
// Random color generator for cursor colors
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',
];
const getRandomColor = () => colors[Math.floor(Math.random() * colors.length)];
const getRandomName = () => {
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'];
return names[Math.floor(Math.random() * names.length)];
};
interface CollaborativeEditorProps {
roomName: string;
}
export default function CollaborativeEditor({ roomName }: CollaborativeEditorProps) {
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [userCount, setUserCount] = useState(0);
// Create Y.js document and WebSocket provider
const { ydoc, provider } = useMemo(() => {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
'ws://localhost:4444',
roomName,
ydoc,
{ connect: true }
);
// Set user awareness info
provider.awareness.setLocalStateField('user', {
name: getRandomName(),
color: getRandomColor(),
});
return { ydoc, provider };
}, [roomName]);
// Track connection status
useEffect(() => {
const onStatus = ({ status: s }: { status: string }) => {
setStatus(s as any);
};
provider.on('status', onStatus);
const onAwarenessChange = () => {
setUserCount(provider.awareness.getStates().size);
};
provider.awareness.on('change', onAwarenessChange);
return () => {
provider.off('status', onStatus);
provider.awareness.off('change', onAwarenessChange);
provider.destroy();
ydoc.destroy();
};
}, [provider, ydoc]);
// Initialize TipTap editor with collaboration extensions
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable default history — Y.js handles undo/redo
history: false,
}),
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
user: provider.awareness.getLocalState()?.user,
}),
],
editorProps: {
attributes: {
class:
'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-6',
},
},
}, [ydoc, provider]);
return (
<div className="collaborative-editor">
{/ Status bar /}
<div className="flex items-center justify-between p-3 bg-gray-100 rounded-t-lg border border-b-0">
<div className="flex items-center gap-2">
<div
className={
w-2 h-2 rounded-full ${status === 'connected'
? 'bg-green-500'
: status === 'connecting'
? 'bg-yellow-500'
: 'bg-red-500'
}
/>
<span className="text-sm text-gray-600 capitalize">{status}</span>
</div>
<span className="text-sm text-gray-500">
{userCount} user{userCount !== 1 ? 's' : ''} online
</span>
</div>
{/ Toolbar /}
{editor && (
<div className="flex gap-1 p-2 border border-b-0 bg-white">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
>
B
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
>
I
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
>
S
</ToolbarButton>
<span className="w-px bg-gray-300 mx-1" />
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
active={editor.isActive('heading', { level: 1 })}
>
H1
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
active={editor.isActive('heading', { level: 2 })}
>
H2
</ToolbarButton>
<span className="w-px bg-gray-300 mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
>
• List
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
>
Code
</ToolbarButton>
</div>
)}
{/ Editor /}
<div className="border rounded-b-lg">
<EditorContent editor={editor} />
</div>
</div>
);
}
function ToolbarButton({
onClick,
active,
children,
}: {
onClick: () => void;
active: boolean;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={
px-3 py-1 rounded text-sm font-medium transition-colors ${active
? 'bg-blue-100 text-blue-700'
: 'hover:bg-gray-100 text-gray-700'
}}
>
{children}
</button>
);
}
Step 5: Style the Collaborative Cursors
Create client/src/editor.css to style remote cursors:
/ Remote user cursors /
.collaboration-cursor__caret {
border-left: 2px solid;
border-right: none;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/ Cursor label (username) /
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: white;
font-size: 11px;
font-weight: 600;
left: -1px;
line-height: 1;
padding: 2px 6px;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
/ Selection highlight for remote users /
.ProseMirror .selection {
display: inline;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
Step 6: Wire Up the App
Update client/src/App.tsx:
import { useState } from 'react';
import CollaborativeEditor from './CollaborativeEditor';
import './editor.css';
export default function App() {
const [roomName, setRoomName] = useState('my-document');
const [joined, setJoined] = useState(false);
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<h1 className="text-2xl font-bold mb-6 text-center">
Collaborative Editor
</h1>
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="Enter room name..."
className="w-full px-4 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500 outline-none"
/>
<button
onClick={() => setJoined(true)}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition"
>
Join Room
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Room: {roomName}</h1>
<button
onClick={() => setJoined(false)}
className="text-gray-500 hover:text-gray-700"
>
Leave Room
</button>
</div>
<CollaborativeEditor roomName={roomName} />
</div>
</div>
);
}
Step 7: Add Offline Support
Y.js supports offline editing natively. Add IndexedDB persistence so changes survive browser refreshes:
cd client
npm install y-indexeddb
Update the provider setup in CollaborativeEditor.tsx:
import { IndexeddbPersistence } from 'y-indexeddb';
// Inside the useMemo:
const { ydoc, provider, indexeddbProvider } = useMemo(() => {
const ydoc = new Y.Doc();
// Persist to IndexedDB for offline support
const indexeddbProvider = new IndexeddbPersistence(roomName, ydoc);
indexeddbProvider.on('synced', () => {
console.log('Loaded from IndexedDB');
});
// Connect to WebSocket server
const provider = new WebsocketProvider(
'ws://localhost:4444',
roomName,
ydoc,
{ connect: true }
);
provider.awareness.setLocalStateField('user', {
name: getRandomName(),
color: getRandomColor(),
});
return { ydoc, provider, indexeddbProvider };
}, [roomName]);
Now when a user goes offline, they can continue editing. When they reconnect, Y.js automatically merges all changes — no conflicts.
Step 8: Undo/Redo with Y.UndoManager
Y.js provides collaborative-aware undo/redo. Only the current user's changes are undone:
import { UndoManager } from 'yjs';
// After editor creation:
const type = ydoc.getXmlFragment('default');
const undoManager = new UndoManager(type, {
trackedOrigins: new Set([null]),
});
// Add keyboard shortcuts in TipTap:
import { Extension } from '@tiptap/core';
const YjsUndo = Extension.create({
name: 'yjsUndo',
addKeyboardShortcuts() {
return {
'Mod-z': () => {
undoManager.undo();
return true;
},
'Mod-Shift-z': () => {
undoManager.redo();
return true;
},
};
},
});
Step 9: Production Deployment
Dockerize the Server
Create server/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
EXPOSE 4444
VOLUME /app/docs
CMD ["node", "dist/index.js"]
Docker Compose
version: '3.8'
services:
server:
build: ./server
ports:
- '4444:4444'
volumes:
- doc-data:/app/docs
restart: unless-stopped
client:
build: ./client
ports:
- '3000:80'
depends_on:
- server
volumes:
doc-data:
Nginx Reverse Proxy
upstream ws_backend {
server localhost:4444;
}
server {
listen 443 ssl;
server_name collab.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/collab.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/collab.yourdomain.com/privkey.pem;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
location / {
root /var/www/collab-editor;
try_files $uri $uri/ /index.html;
}
}
Step 10: Scaling Considerations
For scaling beyond a single server:
1. Redis Pub/Sub: Use y-redis to sync document updates across multiple server instances
2. Database Persistence: Replace file persistence with PostgreSQL or MongoDB using y-leveldb or a custom adapter
3. Authentication: Add JWT verification in the WebSocket connection handler
4. Rate Limiting: Limit update frequency per client to prevent abuse
// Example: JWT auth on WebSocket connection
import jwt from 'jsonwebtoken';
wss.on('connection', (ws, req) => {
const url = new URL(req.url!, http://${req.headers.host});
const token = url.searchParams.get('token');
try {
const user = jwt.verify(token!, process.env.JWT_SECRET!);
// Proceed with connection
} catch {
ws.close(4401, 'Unauthorized');
return;
}
});
Summary
You've built a complete real-time collaborative editor with:
| Feature | Technology |
|---------|------------|
| CRDT Document Model | Y.js |
| Real-time Sync | WebSocket + y-websocket |
| Rich Text Editor | TipTap (ProseMirror) |
| Shared Cursors | Awareness Protocol |
| Offline Support | y-indexeddb |
| Persistence | File system / LevelDB |
| Undo/Redo | Y.UndoManager |
Y.js makes collaborative editing surprisingly approachable. The CRDT approach eliminates conflict resolution complexity, and the ecosystem of providers (WebSocket, WebRTC, IndexedDB) gives you flexibility in how you deploy. Start with this foundation and extend it with authentication, permissions, comments, and version history to build a production-grade collaborative platform.