Real-time collaborative editing — multiple users editing the same document simultaneously — requires conflict resolution that merges concurrent changes correctly. Claude Code generates Yjs CRDT implementations, Y-WebSocket server configurations, and the awareness protocol that shows who is where in the document.
Yjs Shared Document
Add collaborative editing to our markdown editor.
Multiple users should be able to edit simultaneously.
Show each user's cursor position in real-time.
Server: Y-WebSocket Provider
// server/collaboration.ts
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
import { createServer } from 'http';
// y-websocket handles all CRDT synchronization automatically
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (conn, req) => {
// Extract room from URL: /collab/doc-123
const docName = req.url?.slice(1) ?? 'default';
setupWSConnection(conn, req, {
docName,
gc: true, // Garbage collect old tombstones
});
});
httpServer.listen(4444, () => console.log('Y-WebSocket server on :4444'));
For production: use y-redis to persist documents across server restarts and enable horizontal scaling.
# y-redis: uses Redis for storage + pub/sub for multi-server sync
npm install y-redis @y-redis/server
# Server with Redis backend
// server/collaboration-redis.ts
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { createWSServer } from '@y-redis/server';
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
const { handleConnection } = createWSServer({
redis: process.env.REDIS_URL!,
storage: 'redis',
});
wss.on('connection', handleConnection);
httpServer.listen(4444);
Client: Collaborative Editor
// components/CollaborativeEditor.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
interface Collaborator {
clientId: number;
name: string;
color: string;
cursor: number;
}
export function CollaborativeEditor({ docId, userName }: { docId: string; userName: string }) {
const editorRef = useRef<HTMLDivElement>(null);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [connected, setConnected] = useState(false);
const [synced, setSynced] = useState(false);
useEffect(() => {
if (!editorRef.current) return;
// Yjs document — the shared state
const ydoc = new Y.Doc();
const ytext = ydoc.getText('quill');
// WebSocket connection to y-websocket server
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_COLLAB_WS_URL!,
docId,
ydoc,
);
// Awareness: share cursor position and user identity
const awareness = provider.awareness;
const userColor = `hsl(${Math.random() * 360}, 70%, 50%)`;
awareness.setLocalState({
name: userName,
color: userColor,
cursor: null,
});
// Track connected collaborators
awareness.on('change', () => {
const states = Array.from(awareness.getStates().entries())
.filter(([clientId]) => clientId !== awareness.clientID)
.map(([clientId, state]) => ({
clientId,
name: state.name ?? 'Anonymous',
color: state.color ?? '#999',
cursor: state.cursor ?? 0,
}));
setCollaborators(states);
});
provider.on('status', ({ status }: { status: string }) => {
setConnected(status === 'connected');
});
provider.on('sync', (isSynced: boolean) => {
setSynced(isSynced);
});
// Initialize Quill editor
const quill = new Quill(editorRef.current, {
modules: { toolbar: [['bold', 'italic'], [{ list: 'ordered' }, { list: 'bullet' }], ['link']] },
theme: 'snow',
});
// Bind Quill to the Yjs shared text
const binding = new QuillBinding(ytext, quill, awareness);
// Update awareness cursor on selection change
quill.on('selection-change', (range) => {
awareness.setLocalStateField('cursor', range?.index ?? null);
});
return () => {
binding.destroy();
provider.disconnect();
ydoc.destroy();
};
}, [docId, userName]);
return (
<div>
<div className="collab-status">
<span className={`status-dot ${connected ? 'connected' : 'disconnected'}`} />
{connected ? (synced ? 'Synced' : 'Syncing...') : 'Connecting...'}
{collaborators.length > 0 && (
<div className="collaborators">
{collaborators.map(c => (
<div key={c.clientId} className="collaborator-badge" style={{ backgroundColor: c.color }}>
{c.name[0].toUpperCase()}
</div>
))}
<span>{collaborators.length} other{collaborators.length > 1 ? 's' : ''} editing</span>
</div>
)}
</div>
<div ref={editorRef} />
</div>
);
}
CodeMirror 6 Collaborative Editing
Add collaborative editing to our code editor built on CodeMirror 6.
Multiple users edit the same file with shared cursor positions.
// components/CollaborativeCodeEditor.tsx
import { useEffect, useRef } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { keymap } from '@codemirror/view';
export function CollaborativeCodeEditor({ fileId, userId, userName }: {
fileId: string;
userId: string;
userName: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const ydoc = new Y.Doc();
const ytext = ydoc.getText('codemirror');
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_COLLAB_WS_URL!,
`file:${fileId}`,
ydoc,
);
const colors = ['#e91e63', '#2196f3', '#4caf50', '#ff9800', '#9c27b0'];
const userColor = colors[parseInt(userId.slice(-1), 16) % colors.length];
provider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
colorLight: userColor + '33', // Transparent for selection highlight
});
const view = new EditorView({
doc: ytext.toString(),
extensions: [
basicSetup,
javascript({ typescript: true }),
yCollab(ytext, provider.awareness),
keymap.of(yUndoManagerKeymap),
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': { overflow: 'auto' },
}),
],
parent: containerRef.current,
});
return () => {
view.destroy();
provider.disconnect();
ydoc.destroy();
};
}, [fileId, userId, userName]);
return <div ref={containerRef} style={{ height: '100%', width: '100%' }} />;
}
Shared State Beyond Text
Use Yjs for shared state in a collaborative whiteboard:
shapes that multiple users can add, move, and delete simultaneously.
// For structured data: Y.Map and Y.Array are CRDTs too
const ydoc = new Y.Doc();
const shapes = ydoc.getMap<{
id: string;
type: 'rect' | 'circle' | 'text';
x: number;
y: number;
width: number;
height: number;
color: string;
}>('shapes');
// Add shape — syncs to all peers automatically
function addShape(shape: Shape) {
ydoc.transact(() => {
shapes.set(shape.id, shape);
});
}
// Move shape — concurrent moves merge correctly (last writer wins for position)
function moveShape(id: string, x: number, y: number) {
const shape = shapes.get(id);
if (shape) {
ydoc.transact(() => {
shapes.set(id, { ...shape, x, y });
});
}
}
// Delete — handled correctly even with concurrent modifications
function deleteShape(id: string) {
ydoc.transact(() => {
shapes.delete(id);
});
}
// Subscribe to changes
shapes.observe((event) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'add' || change.action === 'update') {
renderShape(shapes.get(key)!);
} else if (change.action === 'delete') {
removeShape(key);
}
});
});
For the WebSocket infrastructure that scales the collaboration server, see the WebSocket scaling guide. For building document editing with rich formatting, see the React 19 guide for the latest form/action patterns. The Claude Skills 360 bundle includes real-time collaboration skill sets for Yjs, CRDT patterns, and editor integration. Start with the free tier to try collaborative editing scaffolding.