Building a Real-Time Collaborative Block Editor: Integrating Editor.js and Yjs in Node.js
A practical, code-heavy architectural guide on combining block-based Editor.js with Yjs and WebSockets in Node.js to implement a Notion-style collaborative block editor.
Building a Real-Time Collaborative Block Editor: Integrating Editor.js and Yjs in Node.js
Modern web applications demand real-time collaboration. Users expect to co-author documents simultaneously, seeing each other’s changes instantly, much like Google Docs or Notion. While traditional rich text editors rely on a single continuous HTML string (which makes operational transformation or conflict-free replicated data types notoriously difficult to implement), block-based editors break content down into discrete, manageable JSON objects.
In this architectural and practical guide, we will build a real-time collaborative block editor from scratch. We will pair Editor.js—a popular block-style editor that outputs clean JSON—with Yjs, a high-performance CRDT (Conflict-free Replicated Data Type) framework, synchronized over a custom Node.js WebSocket backend.
Architectural Overview
Before writing code, let’s understand the data flow of our collaborative system. Traditional client-server architectures fail for real-time collaboration because routing all edits through a centralized database introduces unacceptable latency and concurrency bottlenecks.
Instead, we adopt a Peer-to-Peer over Server-Relay (Mesh/Hub-and-Spoke) model using CRDTs:
- Local-First Mutations: Every user interaction (typing, adding a block, reordering) mutates a local CRDT document state instantaneously.
- State Vector Synchronization: Yjs calculates minimal binary updates (deltas) representing the local changes.
- WebSocket Transport: These binary deltas are transmitted over a persistent WebSocket connection to a lightweight Node.js backend.
- Server Relay & Persistence: The Node.js server acts as a dumb message relay (and persistence layer), broadcasting the binary update to all other connected clients in the same document room.
- Deterministic Merge: Remote clients receive the update, apply it to their local Yjs document instance via
Y.applyUpdate, and mathematically resolve any conflicts without locks or central arbitration.
+------------------+ WebSocket +------------------+
| Client A (UI) | <-------------------------------> | |
+------------------+ | |
| Yjs bindings | Node.js |
v | WebSocket |
+------------------+ WebSocket | Server |
| Client B (UI) | <-------------------------------> | (Relay & DB) |
+------------------+ | |
+------------------+
Setting Up the Node.js WebSocket Server
We will start by building our backend. The Node.js server needs to handle raw WebSocket connections, manage rooms (documents), and relay Yjs binary updates between peers. We will use the ws library alongside y-websocket/bin/utils helper patterns to manage state.
1. Project Initialization
Create a new directory and install dependencies:
mkdir collaborative-editor-backend
cd collaborative-editor-backend
npm init -y
npm install ws yjs
npm install --save-dev nodemon
2. The WebSocket Relay Server (server.js)
Create server.js. This script sets up an HTTP server, attaches a WebSocket server, manages document rooms using y-websocket’s internal data structures, and handles binary synchronization.
const http = require('http');
const { WebSocketServer } = require('ws');
const Y = require('yjs');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Collaborative Editor WebSocket Server Running\n');
});
const wss = new WebSocketServer({ noServer: true });
// Map to store Yjs documents per room/document ID
const docs = new Map();
function getYDoc(roomName) {
let doc = docs.get(roomName);
if (!doc) {
doc = new Y.Doc();
docs.set(roomName, doc);
// Optional: Load initial document state from database here
doc.on('update', (update, origin) => {
// Broadcast update to all other clients in the same room
// Handled in connection handler below
});
}
return doc;
}
wss.on('connection', (conn, req, roomName) => {
const doc = getYDoc(roomName);
// Keep track of client subscriptions
conn.isAlive = true;
conn.on('pong', () => { conn.isAlive = true; });
console.log(`Client connected to room: ${roomName}`);
// Send sync step 1: Send current document state to new client
const encoder = Y.encodeStateAsUpdate(doc);
conn.send(encoder);
// Listen for messages from client
conn.on('message', (message) => {
try {
const update = new Uint8Array(message);
Y.applyUpdate(doc, update, conn);
// Broadcast to all other clients in the same room
wss.clients.forEach((client) => {
if (client !== conn && client.readyState === ws.OPEN) {
client.send(message);
}
});
} catch (err) {
console.error('Error applying update:', err);
}
});
conn.on('close', () => {
console.log(`Client disconnected from room: ${roomName}`);
});
});
// Handle upgrade requests to route rooms via URL paths e.g., /ws/doc-123
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const roomName = url.pathname.slice(1) || 'default-room';
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, roomName);
});
});
// Heartbeat to clear dead connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
const PORT = process.env.PORT || 1234;
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
Building the Frontend Client
Now, let’s build the client-side integration. We need to bridge Editor.js (which thinks in terms of blocks and component renders) with Yjs (which thinks in terms of shared maps, arrays, and types).
Client Dependencies
In your frontend application (Vite, Next.js, or vanilla setup), install the required packages:
npm install @editorjs/editorjs @editorjs/header @editorjs/list yjs
Designing the Editor.js + Yjs Binding
Editor.js expects an imperative API (editor.save(), editor.render()), whereas CRDTs are reactive. To bridge this, we establish a Yjs Y.Map or Y.Array that represents our blocks. When Yjs registers a remote update, we update Editor.js. When Editor.js triggers a change event, we update Yjs.
Here is a robust implementation of the synchronization layer:
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import List from '@editorjs/list';
import * as Y from 'yjs';
// 1. Initialize Yjs Document and Websocket Provider
const doc = new Y.Doc();
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//localhost:1234/my-document-id`;
const wsConn = new WebSocket(wsUrl);
wsConn.binaryType = 'arraybuffer';
wsConn.onopen = () => {
console.log('Connected to WebSocket server');
};
wsConn.onmessage = (event) => {
const update = new Uint8Array(event.data);
Y.applyUpdate(doc, update);
};
// Send local updates to server
doc.on('update', (update) => {
if (wsConn.readyState === WebSocket.OPEN) {
wsConn.send(update);
}
});
// 2. Define Shared Yjs Type for Editor Blocks
const sharedBlocks = doc.getArray('editor-blocks');
// Flag to prevent recursive rendering loops
let isRemoteUpdate = false;
// 3. Initialize Editor.js
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: Header,
list: List,
},
data: {
blocks: getYjsBlocksAsEditorData(sharedBlocks)
},
onChange: async (api, event) => {
if (isRemoteUpdate) return;
// Save current editor state and update Yjs array
const savedData = await api.saver.save();
updateYjsFromEditorData(savedData.blocks);
}
});
// Helper: Convert Yjs array structure to Editor.js data format
function getYjsBlocksAsEditorData(yArray) {
const jsonBlocks = yArray.toJSON();
return jsonBlocks.length > 0 ? jsonBlocks : [
{ type: 'paragraph', data: { text: 'Start collaborating here...' } }
];
}
// Helper: Sync Editor.js changes into the Yjs shared array transactionally
function updateYjsFromEditorData(blocks) {
doc.transact(() => {
sharedBlocks.delete(0, sharedBlocks.length);
blocks.forEach(block => {
sharedBlocks.push([block]);
});
}, 'local-editor-change');
}
// 4. Listen for Remote Yjs Changes and Update Editor.js UI
sharedBlocks.observe((event) => {
// If the change originated locally from our own editor, ignore UI re-render
if (event.transaction.origin === 'local-editor-change') return;
isRemoteUpdate = true;
editor.isReady
.then(async () => {
const freshData = getYjsBlocksAsEditorData(sharedBlocks);
await editor.render({ blocks: freshData });
})
.catch((err) => {
console.error('Failed to render remote blocks:', err);
})
.finally(() => {
isRemoteUpdate = false;
});
});
Handling Edge Cases and Conflict Resolution
While the code above establishes baseline collaboration, production applications require handling complex edge cases inherent to distributed block editors.
1. Cursor Jumping and Focus Loss
When a remote update triggers editor.render(), Editor.js completely destroys and recreates the DOM nodes inside the editor holder. If User A is typing in Block 2 and User B inserts a block above it, User A will lose their cursor focus and text selection.
Mitigation Strategy: Instead of wiping and re-rendering the entire document on every Yjs event, implement granular block-level diffing:
// Instead of editor.render({ blocks }), update specific modified blocks
sharedBlocks.observeDeep((events) => {
events.forEach(event => {
// Inspect event path to update only the modified block ID
});
});
Alternatively, capture the active element and selection offsets before rendering, and restore them post-render.
2. Transaction Origin Tagging
Always use transaction origins (doc.transact(fn, origin)) in Yjs. This prevents infinite loops where:
- User types in Editor.js $\rightarrow$ fires
onChange onChangeupdates Yjs array $\rightarrow$ firesdoc.on('update')doc.on('update')triggers remote listener $\rightarrow$ triggerseditor.render()editor.render()firesonChangeagain.
Persistence and Database Storage
In a production environment, relying solely on in-memory Yjs documents (const docs = new Map()) means data is lost if the Node.js process restarts. Yjs provides robust binary serialization ideal for databases like PostgreSQL, MongoDB, or Redis.
Saving State to Disk/Database
You can persist the binary state vector of a Yjs document whenever updates settle or debounced intervals pass:
const fs = require('fs');
function persistDoc(roomName, doc) {
const update = Y.encodeStateAsUpdate(doc);
// Save Uint8Array binary buffer to database column (e.g., BYTEA in Postgres)
fs.writeFileSync(`./data/${roomName}.yjs`, update);
}
// Load on startup
function loadDoc(roomName, doc) {
const path = `./data/${roomName}.yjs`;
if (fs.existsSync(path)) {
const update = fs.readFileSync(path);
Y.applyUpdate(doc, update);
}
}
Conclusion
By pairing Editor.js with Yjs and a lightweight Node.js WebSocket relay, you can build lightning-fast, highly resilient, block-based collaborative editors without paying for expensive third-party SaaS infrastructure.
Key Takeaways:
- CRDTs over OT: Yjs eliminates the need for complex operational transformation central servers, allowing decentralized, robust conflict resolution.
- Block Granularity: Editor.js outputs clean, structured JSON blocks, making it an exceptional candidate for state mapping.
- Transaction Guards: Always isolate local UI mutations from remote network updates using transaction origins to prevent infinite rendering loops.
Now, hook up user presence cursors using Yjs Awareness (provider.awareness), add database persistence, and scale your collaborative editor to thousands of concurrent users!