Building Offline-First Collaborative Editors with CRDTs and IndexedDB (No Backend Needed)
Modern collaborative tools like Notion, Google Docs, and Linear are powered by real-time sync and conflict resolution. But did you know you can build similar collaboration without any backend at all? In this article, we’ll build an offline-first collaborative editor using Yjs (a CRDT library), storing changes locally via IndexedDB, and syncing them manually or peer-to-peer. No server, no database — just local-first magic. Step 1: Install Yjs and the IndexedDB Adapter Yjs is a powerful CRDT implementation that enables real-time syncing and automatic merge conflict resolution. npm install yjs y-indexeddb Then import them in your React app: import * as Y from 'yjs'; import { IndexeddbPersistence } from 'y-indexeddb'; Step 2: Create a Shared Yjs Document You start by creating a shared Y.Doc, which will hold all collaborative state. const ydoc = new Y.Doc(); Hook up persistence to IndexedDB: const persistence = new IndexeddbPersistence('my-doc', ydoc); persistence.on('synced', () => { console.log('Loaded from IndexedDB'); }); This allows your doc to persist across page loads, offline sessions, and reboots — entirely on the client. Step 3: Bind to a Shared Text Field You can now define a shared text CRDT structure: const yText = ydoc.getText('editor'); Listen for changes: yText.observe(event => { console.log('Text updated:', yText.toString()); }); Step 4: Bind to a React Component Let’s bind this collaborative CRDT to a React text editor (e.g., a ``): function CollaborativeEditor() { const [text, setText] = useState(''); useEffect(() => { const updateText = () => setText(yText.toString()); yText.observe(updateText); updateText(); return () => yText.unobserve(updateText); }, []); const handleChange = (e) => { yText.delete(0, yText.length); yText.insert(0, e.target.value); }; return ( ); } Now all changes go through Yjs and are automatically conflict-resolved, even across tabs. Step 5: Add Peer-to-Peer Sync with WebRTC (Optional) You can optionally add peer-to-peer sync using the Yjs WebRTC provider: npm install y-webrtc import { WebrtcProvider } from 'y-webrtc'; const provider = new WebrtcProvider('room-id', ydoc); Now all participants in the same "room" will share updates live — no backend required. Step 6: Add Manual Sync Export/Import Allow users to export/import their local doc for offline collaboration: function exportDoc() { const update = Y.encodeStateAsUpdate(ydoc); const blob = new Blob([update], { type: 'application/octet-stream' }); saveAs(blob, 'doc.ydoc'); } function importDoc(file) { const reader = new FileReader(); reader.onload = () => { const update = new Uint8Array(reader.result); Y.applyUpdate(ydoc, update); }; reader.readAsArrayBuffer(file); } This allows manual collaboration workflows (think encrypted file sharing or offline USB syncing). ✅ Pros:
Modern collaborative tools like Notion, Google Docs, and Linear are powered by real-time sync and conflict resolution. But did you know you can build similar collaboration without any backend at all?
In this article, we’ll build an offline-first collaborative editor using Yjs (a CRDT library), storing changes locally via IndexedDB, and syncing them manually or peer-to-peer. No server, no database — just local-first magic.
Step 1: Install Yjs and the IndexedDB Adapter
Yjs is a powerful CRDT implementation that enables real-time syncing and automatic merge conflict resolution.
npm install yjs y-indexeddb
Then import them in your React app:
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
Step 2: Create a Shared Yjs Document
You start by creating a shared Y.Doc
, which will hold all collaborative state.
const ydoc = new Y.Doc();
Hook up persistence to IndexedDB:
const persistence = new IndexeddbPersistence('my-doc', ydoc);
persistence.on('synced', () => {
console.log('Loaded from IndexedDB');
});
This allows your doc to persist across page loads, offline sessions, and reboots — entirely on the client.
Step 3: Bind to a Shared Text Field
You can now define a shared text CRDT structure:
const yText = ydoc.getText('editor');
Listen for changes:
yText.observe(event => {
console.log('Text updated:', yText.toString());
});
Step 4: Bind to a React Component
Let’s bind this collaborative CRDT to a React text editor (e.g., a ``):
function CollaborativeEditor() {
const [text, setText] = useState('');
useEffect(() => {
const updateText = () => setText(yText.toString());
yText.observe(updateText);
updateText();
return () => yText.unobserve(updateText);
}, []);
const handleChange = (e) => {
yText.delete(0, yText.length);
yText.insert(0, e.target.value);
};
return (
);
}
Now all changes go through Yjs and are automatically conflict-resolved, even across tabs.
Step 5: Add Peer-to-Peer Sync with WebRTC (Optional)
You can optionally add peer-to-peer sync using the Yjs WebRTC provider:
npm install y-webrtc
import { WebrtcProvider } from 'y-webrtc';
const provider = new WebrtcProvider('room-id', ydoc);
Now all participants in the same "room" will share updates live — no backend required.
Step 6: Add Manual Sync Export/Import
Allow users to export/import their local doc for offline collaboration:
function exportDoc() {
const update = Y.encodeStateAsUpdate(ydoc);
const blob = new Blob([update], { type: 'application/octet-stream' });
saveAs(blob, 'doc.ydoc');
}
function importDoc(file) {
const reader = new FileReader();
reader.onload = () => {
const update = new Uint8Array(reader.result);
Y.applyUpdate(ydoc, update);
};
reader.readAsArrayBuffer(file);
}
This allows manual collaboration workflows (think encrypted file sharing or offline USB syncing).
✅ Pros: