Building Local-First Collaborative Apps with Yjs and React

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

TypeHow It WorksExample
State-based (CvRDT)Merges full state snapshotsG-Counter, LWW-Register
Operation-based (CmRDT)Sends and applies individual operationsYjs, 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-editor

Install Yjs and its ecosystem packages:

npm install yjs y-websocket y-indexeddb @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor

Here is what each package does:

PackagePurpose
yjsCore CRDT library
y-websocketWebSocket provider for real-time sync
y-indexeddbIndexedDB provider for offline persistence
@tiptap/reactRich text editor framework for React
@tiptap/starter-kitEssential Tiptap extensions (bold, italic, headings)
@tiptap/extension-collaborationYjs integration for Tiptap
@tiptap/extension-collaboration-cursorShow 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-websocket

This 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-leveldb

Step 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 dev

Now test the collaboration:

  1. Open two browser tabs pointing to http://localhost:5173
  2. Type in one tab — you should see the text appear instantly in the other
  3. Notice the colored cursors showing each user's position
  4. Open DevTools and go to Application and then IndexedDB — you will see the Yjs data stored locally
  5. Disconnect your network (toggle airplane mode or use DevTools Network tab)
  6. Continue typing — changes are saved locally
  7. 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-indexeddb is properly initialized
  • Check IndexedDB in DevTools to verify data is stored
  • The roomName must match between sessions

Cursor names not showing

  • Verify awareness state is set before the editor initializes
  • Check that CollaborationCursor extension 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 whenSynced resolves
  • Check for room name mismatches between providers

Comparing Local-First Solutions

LibrarySizeLanguageBest For
Yjs16KBJS/TSText editing, general CRDT needs
Automerge90KBJS/Rust (WASM)JSON-like document sync
LiveblocksHostedJS/TSManaged real-time collaboration
PartyKitHostedJS/TSServerless real-time rooms
ElectricSQLVariesSQLSyncing 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.


Want to read more tutorials? Check out our latest tutorial on React Router v7: Build a Full-Stack App with Framework Mode.

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.

30 min read·

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.

25 min read·