Building Local-First Collaborative Apps with Yjs and React

The way we build web applications is shifting. For years, the cloud-first model dominated: every keystroke travels to a server, gets processed, and returns to the client. But what happens when the network drops? What if the server is slow? What if two people edit the same document simultaneously?
Local-first software solves these problems by keeping data on the user's device and syncing it in the background. The result: instant interactions, full offline support, and seamless real-time collaboration without conflicts.
What you will build: A collaborative text editor where multiple users can edit the same document simultaneously — even while offline. Changes sync automatically when connectivity is restored, with zero conflicts.
What You Will Learn
By the end of this tutorial, you will understand:
- What CRDTs are and why they enable conflict-free collaboration
- How to integrate Yjs (a production-grade CRDT library) with React
- How to build a shared document editor with real-time sync
- How to add offline support with persistent local storage
- How to set up a WebSocket signaling server for peer-to-peer sync
- Strategies for scaling local-first apps in production
Understanding CRDTs and Local-First Architecture
The Problem with Traditional Sync
In a typical cloud-first app, two users editing the same field creates a conflict. User A changes a title to "Hello" while User B changes it to "World" — one change wins, the other is lost. This is the last-write-wins problem.
Traditional solutions involve:
- Locking — only one person can edit at a time (frustrating)
- Operational Transformation (OT) — complex algorithms that require a central server (used by Google Docs)
- Manual conflict resolution — showing users a diff and asking them to merge (slow)
CRDTs: A Better Approach
Conflict-free Replicated Data Types (CRDTs) are data structures designed so that concurrent edits always converge to the same state, regardless of the order they are applied. No conflicts, no central server required.
There are two main types:
| Type | How It Works | Example |
|---|---|---|
| State-based (CvRDT) | Merges full state snapshots | G-Counter, LWW-Register |
| Operation-based (CmRDT) | Sends and applies individual operations | Yjs, Automerge |
Yjs uses operation-based CRDTs optimized for text editing, making it perfect for collaborative editors, whiteboards, and shared data structures.
Why Yjs?
Yjs stands out among CRDT libraries for several reasons:
- Battle-tested — used by Notion, JupyterLab, and hundreds of production apps
- Tiny footprint — around 16KB gzipped for the core library
- Rich data types — Y.Text, Y.Array, Y.Map, and Y.XmlFragment
- Provider ecosystem — WebSocket, WebRTC, IndexedDB, and more
- Framework agnostic — works with React, Vue, Svelte, or vanilla JS
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- npm or pnpm package manager
- Basic knowledge of React and TypeScript
- A code editor (VS Code recommended)
- Two browser windows for testing collaboration
Step 1: Project Setup
Create a new React project with Vite and install the necessary dependencies:
npm create vite@latest collab-editor -- --template react-ts
cd collab-editorInstall Yjs and its ecosystem packages:
npm install yjs y-websocket y-indexeddb @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursorHere is what each package does:
| Package | Purpose |
|---|---|
yjs | Core CRDT library |
y-websocket | WebSocket provider for real-time sync |
y-indexeddb | IndexedDB provider for offline persistence |
@tiptap/react | Rich text editor framework for React |
@tiptap/starter-kit | Essential Tiptap extensions (bold, italic, headings) |
@tiptap/extension-collaboration | Yjs integration for Tiptap |
@tiptap/extension-collaboration-cursor | Show other users' cursors |
Step 2: Understanding the Yjs Data Model
Before writing code, let's understand how Yjs structures data. A Yjs document (Y.Doc) is a container for shared data types:
import * as Y from 'yjs'
// Create a new Yjs document
const ydoc = new Y.Doc()
// Shared data types - accessed by name
const ytext = ydoc.getText('editor') // Collaborative text
const yarray = ydoc.getArray('items') // Collaborative list
const ymap = ydoc.getMap('metadata') // Collaborative key-value
// Changes are automatically tracked
ytext.insert(0, 'Hello, World!')
ymap.set('title', 'My Document')
yarray.push(['item1', 'item2'])Key concepts:
- Every shared type is accessed by a string name on the document
- All changes are recorded as operations in an append-only log
- Operations can be applied in any order and always converge
- The document can be encoded to a compact binary format for transfer
Step 3: Setting Up the Yjs Document Provider
Create a custom hook that initializes the Yjs document with both WebSocket (for real-time sync) and IndexedDB (for offline persistence):
// src/hooks/useYjsDocument.ts
import { useEffect, useMemo } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
interface UseYjsDocumentOptions {
roomName: string
userName: string
userColor: string
serverUrl?: string
}
export function useYjsDocument({
roomName,
userName,
userColor,
serverUrl = 'ws://localhost:1234',
}: UseYjsDocumentOptions) {
// Create a stable Yjs document
const ydoc = useMemo(() => new Y.Doc(), [])
useEffect(() => {
// Provider 1: WebSocket for real-time sync with peers
const wsProvider = new WebsocketProvider(
serverUrl,
roomName,
ydoc
)
// Set user awareness (cursor position, name, color)
wsProvider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
})
// Provider 2: IndexedDB for offline persistence
const idbProvider = new IndexeddbPersistence(roomName, ydoc)
idbProvider.whenSynced.then(() => {
console.log('Local data loaded from IndexedDB')
})
// Cleanup on unmount
return () => {
wsProvider.destroy()
idbProvider.destroy()
ydoc.destroy()
}
}, [ydoc, roomName, userName, userColor, serverUrl])
return { ydoc }
}How providers work: Yjs providers are pluggable sync adapters. The WebSocket provider syncs changes between connected clients in real-time. The IndexedDB provider persists the document locally so it survives page refreshes and works offline. You can use multiple providers simultaneously — Yjs handles deduplication automatically.
Step 4: Building the Collaborative Editor Component
Now create the editor component using Tiptap, which has first-class Yjs integration:
// src/components/CollaborativeEditor.tsx
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 type { WebsocketProvider } from 'y-websocket'
interface CollaborativeEditorProps {
ydoc: Y.Doc
provider: WebsocketProvider
}
export function CollaborativeEditor({
ydoc,
provider,
}: CollaborativeEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable default history — Yjs handles undo/redo
history: false,
}),
Collaboration.configure({
document: ydoc,
field: 'editor', // Must match the Y.Text name
}),
CollaborationCursor.configure({
provider,
user: {
name: provider.awareness.getLocalState()?.user?.name ?? 'Anonymous',
color: provider.awareness.getLocalState()?.user?.color ?? '#3b82f6',
},
}),
],
})
if (!editor) {
return <div className="editor-loading">Loading editor...</div>
}
return (
<div className="editor-container">
<MenuBar editor={editor} />
<EditorContent editor={editor} className="editor-content" />
<ConnectionStatus provider={provider} />
</div>
)
}Step 5: Adding the Toolbar and Connection Status
Create a simple toolbar for text formatting:
// src/components/MenuBar.tsx
import type { Editor } from '@tiptap/react'
interface MenuBarProps {
editor: Editor
}
export function MenuBar({ editor }: MenuBarProps) {
return (
<div className="menu-bar">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
List
</button>
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
>
Undo
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
>
Redo
</button>
</div>
)
}Now add a connection status indicator that shows online/offline state and connected peers:
// src/components/ConnectionStatus.tsx
import { useEffect, useState } from 'react'
import type { WebsocketProvider } from 'y-websocket'
interface ConnectionStatusProps {
provider: WebsocketProvider
}
interface AwarenessUser {
name: string
color: string
}
export function ConnectionStatus({ provider }: ConnectionStatusProps) {
const [connected, setConnected] = useState(false)
const [peers, setPeers] = useState<AwarenessUser[]>([])
useEffect(() => {
const handleStatus = (event: { status: string }) => {
setConnected(event.status === 'connected')
}
const handleAwareness = () => {
const states = Array.from(provider.awareness.getStates().values())
const users = states
.filter((state) => state.user)
.map((state) => state.user as AwarenessUser)
setPeers(users)
}
provider.on('status', handleStatus)
provider.awareness.on('change', handleAwareness)
// Initial state
setConnected(provider.wsconnected)
handleAwareness()
return () => {
provider.off('status', handleStatus)
provider.awareness.off('change', handleAwareness)
}
}, [provider])
return (
<div className="connection-status">
<span className={`status-dot ${connected ? 'online' : 'offline'}`} />
<span>{connected ? 'Connected' : 'Offline (changes saved locally)'}</span>
<div className="peer-list">
{peers.map((peer, i) => (
<span
key={i}
className="peer-badge"
style={{ backgroundColor: peer.color }}
>
{peer.name}
</span>
))}
</div>
</div>
)
}Step 6: Wiring It All Together
Update the main App component to connect everything:
// src/App.tsx
import { useMemo, useState } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
import { CollaborativeEditor } from './components/CollaborativeEditor'
import './App.css'
// Generate a random color for the user
function getRandomColor(): string {
const colors = [
'#f43f5e', '#8b5cf6', '#3b82f6',
'#10b981', '#f59e0b', '#ec4899',
]
return colors[Math.floor(Math.random() * colors.length)]
}
export default function App() {
const [userName] = useState(
() => `User-${Math.random().toString(36).slice(2, 6)}`
)
const [userColor] = useState(getRandomColor)
const roomName = 'collab-demo-room'
// Initialize Yjs document and providers
const { ydoc, provider } = useMemo(() => {
const doc = new Y.Doc()
// Real-time sync via WebSocket
const wsProvider = new WebsocketProvider(
'ws://localhost:1234',
roomName,
doc
)
wsProvider.awareness.setLocalStateField('user', {
name: userName,
color: userColor,
})
// Offline persistence via IndexedDB
new IndexeddbPersistence(roomName, doc)
return { ydoc: doc, provider: wsProvider }
}, [roomName, userName, userColor])
return (
<div className="app">
<header className="app-header">
<h1>Collaborative Editor</h1>
<p>Open this page in multiple tabs to test real-time collaboration</p>
</header>
<CollaborativeEditor ydoc={ydoc} provider={provider} />
</div>
)
}Step 7: Adding Styles
Add styles that include cursor rendering for collaboration:
/* src/App.css */
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.app-header {
margin-bottom: 2rem;
text-align: center;
}
.editor-container {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.menu-bar {
display: flex;
gap: 4px;
padding: 8px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.menu-bar button {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
}
.menu-bar button.is-active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.menu-bar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.editor-content .tiptap {
padding: 1rem;
min-height: 400px;
outline: none;
}
.editor-content .tiptap p {
margin: 0.5em 0;
}
/* Collaboration cursor styles */
.collaboration-cursor__caret {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid;
border-right: 1px solid;
word-break: normal;
pointer-events: none;
}
.collaboration-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: normal;
padding: 0.1rem 0.3rem;
border-radius: 3px 3px 3px 0;
color: white;
white-space: nowrap;
user-select: none;
}
/* Connection status */
.connection-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 13px;
color: #64748b;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.online {
background: #10b981;
}
.status-dot.offline {
background: #ef4444;
}
.peer-list {
display: flex;
gap: 4px;
margin-left: auto;
}
.peer-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
font-weight: 500;
}Step 8: Setting Up the WebSocket Server
Yjs needs a signaling server so clients can discover each other and relay messages. The y-websocket package ships with a ready-to-use server:
npx y-websocketThis starts a WebSocket server on port 1234. For a custom server with more control:
// server.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'
const wss = new WebSocketServer({ port: 1234 })
wss.on('connection', (ws, req) => {
const roomName = req.url?.slice(1) ?? 'default'
console.log(`Client connected to room: ${roomName}`)
setupWSConnection(ws, req)
})
console.log('Yjs WebSocket server running on ws://localhost:1234')Important: The WebSocket server is a relay — it does NOT store documents. If all clients disconnect and their local data is cleared, the document is gone. For production, add server-side persistence using y-leveldb or y-mongodb-provider to persist documents on the server.
Step 9: Adding Server-Side Persistence
For production, you want the server to persist documents so they survive even when all clients disconnect:
// server-persistent.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection, docs } from 'y-websocket/bin/utils'
import { LeveldbPersistence } from 'y-leveldb'
const wss = new WebSocketServer({ port: 1234 })
const ldb = new LeveldbPersistence('./yjs-data')
wss.on('connection', async (ws, req) => {
setupWSConnection(ws, req)
})
// Periodically persist all active documents
setInterval(async () => {
for (const [name, doc] of docs) {
await ldb.storeUpdate(name, Buffer.from(
require('yjs').encodeStateAsUpdate(doc)
))
}
}, 30_000) // Every 30 seconds
console.log('Persistent Yjs server running on ws://localhost:1234')Install the persistence package:
npm install y-leveldbStep 10: Testing Your Collaborative Editor
Start the server and the dev server:
# Terminal 1: Start the Yjs WebSocket server
npx y-websocket
# Terminal 2: Start the Vite dev server
npm run devNow test the collaboration:
- Open two browser tabs pointing to
http://localhost:5173 - Type in one tab — you should see the text appear instantly in the other
- Notice the colored cursors showing each user's position
- Open DevTools and go to Application and then IndexedDB — you will see the Yjs data stored locally
- Disconnect your network (toggle airplane mode or use DevTools Network tab)
- Continue typing — changes are saved locally
- Reconnect — changes from both tabs merge automatically
Going Beyond Text: Shared Data Structures
Yjs is not limited to text editors. You can build collaborative anything using its shared data types:
Collaborative To-Do List
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ytodos = ydoc.getArray<Y.Map<unknown>>('todos')
// Add a todo
function addTodo(text: string) {
const todo = new Y.Map()
todo.set('id', crypto.randomUUID())
todo.set('text', text)
todo.set('done', false)
todo.set('createdAt', Date.now())
ytodos.push([todo])
}
// Toggle a todo
function toggleTodo(index: number) {
const todo = ytodos.get(index) as Y.Map<unknown>
todo.set('done', !todo.get('done'))
}
// React hook to observe changes
function useTodos() {
const [todos, setTodos] = useState<Array<{
id: string; text: string; done: boolean
}>>([])
useEffect(() => {
const observer = () => {
setTodos(
ytodos.toArray().map((item) => {
const map = item as Y.Map<unknown>
return {
id: map.get('id') as string,
text: map.get('text') as string,
done: map.get('done') as boolean,
}
})
)
}
ytodos.observe(observer)
observer() // Initial load
return () => ytodos.unobserve(observer)
}, [])
return { todos, addTodo, toggleTodo }
}Collaborative Drawing Canvas
import * as Y from 'yjs'
const ydoc = new Y.Doc()
const ystrokes = ydoc.getArray<Y.Map<unknown>>('strokes')
// Add a stroke (array of points)
function addStroke(points: Array<{ x: number; y: number }>, color: string) {
const stroke = new Y.Map()
stroke.set('points', points)
stroke.set('color', color)
stroke.set('timestamp', Date.now())
ystrokes.push([stroke])
}Performance Tips for Production
1. Document Size Management
Yjs documents grow over time as edits accumulate. For large documents, use garbage collection:
const ydoc = new Y.Doc({ gc: true }) // Enable garbage collection (default)You can also snapshot and compact documents periodically:
import * as Y from 'yjs'
function compactDocument(ydoc: Y.Doc): Uint8Array {
// Encode the full state — this is already compacted
return Y.encodeStateAsUpdate(ydoc)
}
function loadFromSnapshot(snapshot: Uint8Array): Y.Doc {
const ydoc = new Y.Doc()
Y.applyUpdate(ydoc, snapshot)
return ydoc
}2. Awareness Throttling
By default, awareness updates (cursor positions) are sent on every change. Throttle them for better performance:
const wsProvider = new WebsocketProvider(serverUrl, roomName, ydoc)
// Throttle awareness updates to every 200ms
let awarenessTimeout: ReturnType<typeof setTimeout> | null = null
function throttledAwareness(field: string, value: unknown) {
if (awarenessTimeout) return
awarenessTimeout = setTimeout(() => {
wsProvider.awareness.setLocalStateField(field, value)
awarenessTimeout = null
}, 200)
}3. Lazy Loading for Large Documents
For apps with many documents, load them on demand:
function useYjsDocument(roomName: string | null) {
const [ydoc, setYdoc] = useState<Y.Doc | null>(null)
useEffect(() => {
if (!roomName) return
const doc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', roomName, doc)
setYdoc(doc)
return () => {
provider.destroy()
doc.destroy()
setYdoc(null)
}
}, [roomName])
return ydoc
}Troubleshooting
Changes not syncing between tabs
- Verify the WebSocket server is running on port 1234
- Check browser console for WebSocket connection errors
- Ensure both tabs use the same
roomName
Data lost after page refresh
- Make sure
y-indexeddbis properly initialized - Check IndexedDB in DevTools to verify data is stored
- The
roomNamemust match between sessions
Cursor names not showing
- Verify awareness state is set before the editor initializes
- Check that
CollaborationCursorextension is properly configured - Ensure provider is passed correctly to the cursor extension
Editor loads empty despite existing data
- IndexedDB sync is asynchronous — add a loading state while
whenSyncedresolves - Check for room name mismatches between providers
Comparing Local-First Solutions
| Library | Size | Language | Best For |
|---|---|---|---|
| Yjs | 16KB | JS/TS | Text editing, general CRDT needs |
| Automerge | 90KB | JS/Rust (WASM) | JSON-like document sync |
| Liveblocks | Hosted | JS/TS | Managed real-time collaboration |
| PartyKit | Hosted | JS/TS | Serverless real-time rooms |
| ElectricSQL | Varies | SQL | Syncing Postgres to local SQLite |
Next Steps
Now that you have a working collaborative editor, here are ways to extend it:
- Add authentication — integrate with your auth provider to use real user names
- Add version history — use
Y.snapshot()to save named versions users can restore - Deploy the WebSocket server — host it on Railway, Fly.io, or a VPS behind a reverse proxy
- Add permissions — implement read-only mode by filtering incoming updates
- Build more shared types — collaborative kanban boards, spreadsheets, or design tools
- Try WebRTC — replace the WebSocket server with peer-to-peer sync using
y-webrtc
Conclusion
Local-first architecture represents a fundamental shift in how we build collaborative software. By putting the user's data on their device first and syncing in the background, we get:
- Instant responsiveness — no waiting for network round-trips
- Full offline support — the app works everywhere, always
- Conflict-free collaboration — CRDTs guarantee convergence
- User ownership — data lives on the user's device
Yjs makes this accessible with a small, performant library and a rich ecosystem of providers and integrations. Whether you are building a document editor, a project management tool, or a collaborative whiteboard, the local-first approach with CRDTs is worth exploring for your next project.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.