Real-Time Collaborative Editing Implementation (Yjs/Liveblocks)
Collaborative editing allows multiple users to work simultaneously on one document, seeing each other's changes in real-time. Google Docs-like functionality for web apps.
Approach Choice
Yjs — open-source CRDT (Conflict-free Replicated Data Type) library. Manage server yourself (y-websocket or Hocuspocus).
Liveblocks — managed platform over Yjs. No infrastructure, but paid from $0.
Automerge — alternative to Yjs, from Ink & Switch.
Yjs + Hocuspocus (self-hosted)
Server:
npm install @hocuspocus/server @hocuspocus/extension-database
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { Logger } from '@hocuspocus/extension-logger';
const server = Server.configure({
port: 1234,
extensions: [
new Logger(),
new Database({
fetch: async ({ documentName }) => {
// Load document from DB on first connect
const doc = await documentRepo.findByName(documentName);
return doc?.content ?? null; // Yjs binary state
},
store: async ({ documentName, state }) => {
// Save document state to DB
await documentRepo.upsert(documentName, state);
}
})
],
async onAuthenticate({ token, documentName }) {
// Check access
const payload = jwt.verify(token, process.env.JWT_SECRET);
const canAccess = await checkDocumentAccess(payload.sub, documentName);
if (!canAccess) {
throw new Error('Access denied');
}
return { userId: payload.sub };
},
async onConnect({ documentName, context }) {
console.log(`User ${context.userId} connected to ${documentName}`);
}
});
server.listen();
Client — Tiptap editor with Yjs:
import { useEditor, EditorContent } 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 { HocuspocusProvider } from '@hocuspocus/provider';
function CollaborativeEditor({ documentId }) {
const doc = useMemo(() => new Y.Doc(), []);
const provider = useMemo(() => new HocuspocusProvider({
url: process.env.NEXT_PUBLIC_HOCUSPOCUS_URL,
name: `doc:${documentId}`,
document: doc,
token: getAuthToken(),
onStatus: ({ status }) => console.log('Provider status:', status)
}), [documentId, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc }),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.name,
color: generateUserColor(currentUser.id)
}
})
]
});
return (
<div className="editor-container">
<CollaboratorsAvatars provider={provider} />
<EditorContent editor={editor} />
</div>
);
}
// Active participant avatars
function CollaboratorsAvatars({ provider }) {
const [users, setUsers] = useState([]);
useEffect(() => {
const awareness = provider.awareness;
const updateUsers = () => {
const states = Array.from(awareness.getStates().values());
setUsers(states.filter(s => s.user).map(s => s.user));
};
awareness.on('change', updateUsers);
updateUsers();
return () => awareness.off('change', updateUsers);
}, [provider]);
return (
<div className="collaborators">
{users.map(user => (
<Avatar key={user.id} name={user.name}
color={user.color} title={`${user.name} editing now`} />
))}
</div>
);
}
Liveblocks (managed)
import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react';
import * as Y from 'yjs';
import { LiveblocksYjsProvider } from '@liveblocks/yjs';
const client = createClient({
publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_KEY
});
const { RoomProvider, useRoom } = createRoomContext(client);
function EditorPage({ documentId }) {
return (
<RoomProvider id={`document-${documentId}`} initialPresence={{}}>
<CollaborativeEditorWithLiveblocks />
</RoomProvider>
);
}
function CollaborativeEditorWithLiveblocks() {
const room = useRoom();
const doc = useMemo(() => new Y.Doc(), []);
useEffect(() => {
const provider = new LiveblocksYjsProvider(room, doc);
return () => provider.destroy();
}, [room, doc]);
const editor = useEditor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc })
]
});
return <EditorContent editor={editor} />;
}
Conflict Resolution via CRDT
Yjs uses CRDT — mathematically proven algorithm for merging conflicting changes without coordination. Two users can edit offline, and on sync conflicts resolve deterministically.
Persistence: y-leveldb / PostgreSQL
// Store Yjs documents in PostgreSQL
const documentTable = `
CREATE TABLE IF NOT EXISTS documents (
name VARCHAR(255) PRIMARY KEY,
content BYTEA NOT NULL, -- Yjs binary state
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`;
// Incremental save via y-protocols
import { encodeStateAsUpdate } from 'yjs';
const binaryState = encodeStateAsUpdate(doc);
await db.query(
'INSERT INTO documents (name, content) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET content = $2',
[docName, Buffer.from(binaryState)]
);
Implementation Timeline
- Hocuspocus server + Tiptap client + basic collaborative editing: 2–3 weeks
- Cursors, avatars, PostgreSQL persistence: another 1 week
- Liveblocks integration (no self-hosted server): 1–1.5 weeks







