# Build a Real-Time Collaborative Editor with Y.js, WebSockets, and React

Real-time collaboration has become a must-have feature in modern web applications. From Google Docs to Figma, users expect to edit content simultaneously without conflicts. In this tutorial, you'll build a fully functional collaborative text editor from scratch using Y.js (a CRDT library), WebSockets for real-time sync, and React with TipTap for a rich-text editing experience.

What You'll Build

  • A rich-text editor that multiple users can edit simultaneously
  • Real-time cursor and selection sharing (awareness)
  • A WebSocket server that syncs document state across clients
  • Offline support with automatic reconnection
  • Document persistence to the file system
  • Prerequisites

  • Node.js 18+ and npm/pnpm
  • Basic knowledge of React, TypeScript, and WebSockets
  • Familiarity with rich-text editors is helpful but not required
  • Understanding CRDTs and Y.js

    CRDTs (Conflict-free Replicated Data Types) are data structures that can be independently updated on different replicas and always merge to a consistent state — without coordination. Y.js is the most popular CRDT implementation for JavaScript, offering:

  • Y.Text — collaborative text with formatting attributes
  • Y.Array — shared ordered lists
  • Y.Map — shared key-value maps
  • Y.XmlFragment — shared XML/HTML trees (used by TipTap/ProseMirror)
  • Unlike Operational Transformation (OT) used by Google Docs, CRDTs don't need a central server to resolve conflicts. Every peer converges to the same state automatically.

    Step 1: Project Setup

    Create the project with a monorepo structure:

    mkdir collab-editor && cd collab-editor
    

    mkdir client server

    # Initialize the React client

    cd client

    npm create vite@latest . -- --template react-ts

    npm install

    # Install collaborative editing dependencies

    npm install yjs y-websocket @tiptap/react @tiptap/starter-kit \

    @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor \

    @tiptap/pm

    # Initialize the server

    cd ../server

    npm init -y

    npm install ws yjs y-protocols lib0

    npm install -D typescript @types/ws tsx

    Add a tsconfig.json for the server:

    {
    

    "compilerOptions": {

    "target": "ES2022",

    "module": "ESNext",

    "moduleResolution": "bundler",

    "outDir": "dist",

    "strict": true,

    "esModuleInterop": true

    },

    "include": ["src"]

    }

    Step 2: Build the WebSocket Server

    Create server/src/index.ts:

    import { WebSocketServer, WebSocket } from 'ws';
    

    import * as Y from 'yjs';

    import { encoding, decoding } from 'lib0';

    import fs from 'fs';

    import path from 'path';

    const PORT = 4444;

    const PERSISTENCE_DIR = './docs';

    // Message types matching y-websocket protocol

    const messageSync = 0;

    const messageAwareness = 1;

    interface Room {

    doc: Y.Doc;

    clients: Set<WebSocket>;

    awareness: Map<number, any>;

    }

    const rooms = new Map<string, Room>();

    // Ensure persistence directory exists

    if (!fs.existsSync(PERSISTENCE_DIR)) {

    fs.mkdirSync(PERSISTENCE_DIR, { recursive: true });

    }

    function getRoom(roomName: string): Room {

    if (rooms.has(roomName)) return rooms.get(roomName)!;

    const doc = new Y.Doc();

    // Load persisted state if it exists

    const filePath = path.join(PERSISTENCE_DIR, ${roomName}.yjs);

    if (fs.existsSync(filePath)) {

    const persistedState = fs.readFileSync(filePath);

    Y.applyUpdate(doc, new Uint8Array(persistedState));

    console.log(Loaded persisted document: ${roomName});

    }

    // Persist on every update (debounced in production)

    let persistTimeout: NodeJS.Timeout | null = null;

    doc.on('update', () => {

    if (persistTimeout) clearTimeout(persistTimeout);

    persistTimeout = setTimeout(() => {

    const state = Y.encodeStateAsUpdate(doc);

    fs.writeFileSync(filePath, Buffer.from(state));

    console.log(Persisted document: ${roomName});

    }, 1000); // Debounce 1 second

    });

    const room: Room = {

    doc,

    clients: new Set(),

    awareness: new Map(),

    };

    rooms.set(roomName, room);

    return room;

    }

    function broadcastToRoom(

    room: Room,

    message: Uint8Array,

    exclude?: WebSocket

    ) {

    room.clients.forEach((client) => {

    if (client !== exclude && client.readyState === WebSocket.OPEN) {

    client.send(message);

    }

    });

    }

    const wss = new WebSocketServer({ port: PORT });

    wss.on('connection', (ws, req) => {

    const roomName = req.url?.slice(1) || 'default';

    const room = getRoom(roomName);

    room.clients.add(ws);

    console.log(

    Client connected to room "${roomName}" (${room.clients.size} clients)

    );

    // Send current document state as sync step 1

    const encoder = encoding.createEncoder();

    encoding.writeVarUint(encoder, messageSync);

    const sv = Y.encodeStateVector(room.doc);

    encoding.writeVarUint8Array(encoder, Y.encodeStateAsUpdate(room.doc, sv));

    ws.send(encoding.toUint8Array(encoder));

    // Send existing awareness states

    room.awareness.forEach((state, clientId) => {

    const awarenessEncoder = encoding.createEncoder();

    encoding.writeVarUint(awarenessEncoder, messageAwareness);

    encoding.writeVarString(

    awarenessEncoder,

    JSON.stringify({ clientId, state })

    );

    ws.send(encoding.toUint8Array(awarenessEncoder));

    });

    ws.on('message', (data: Buffer) => {

    const message = new Uint8Array(data);

    const decoder = decoding.createDecoder(message);

    const msgType = decoding.readVarUint(decoder);

    switch (msgType) {

    case messageSync: {

    const update = decoding.readVarUint8Array(decoder);

    Y.applyUpdate(room.doc, update);

    // Broadcast update to other clients

    broadcastToRoom(room, message, ws);

    break;

    }

    case messageAwareness: {

    const awarenessStr = decoding.readVarString(decoder);

    const { clientId, state } = JSON.parse(awarenessStr);

    room.awareness.set(clientId, state);

    // Broadcast awareness to other clients

    broadcastToRoom(room, message, ws);

    break;

    }

    }

    });

    ws.on('close', () => {

    room.clients.delete(ws);

    console.log(

    Client disconnected from room "${roomName}" (${room.clients.size} clients)

    );

    // Clean up empty rooms after a delay

    if (room.clients.size === 0) {

    setTimeout(() => {

    if (room.clients.size === 0) {

    rooms.delete(roomName);

    console.log(Room "${roomName}" cleaned up);

    }

    }, 30000);

    }

    });

    });

    console.log(WebSocket server running on ws://localhost:${PORT});

    This server manages document rooms, persists state to disk, and broadcasts updates between clients.

    Step 3: Using y-websocket Provider (Simplified Approach)

    For production, use the built-in y-websocket provider instead of the custom server above. It handles all the sync protocol details:

    # Start the y-websocket server (built-in)
    

    npx y-websocket --port 4444

    Or create a custom server with persistence using y-websocket/bin/utils:

    import { setupWSConnection } from 'y-websocket/bin/utils';
    

    import { WebSocketServer } from 'ws';

    import http from 'http';

    const server = http.createServer();

    const wss = new WebSocketServer({ server });

    wss.on('connection', (ws, req) => {

    setupWSConnection(ws, req, {

    docName: req.url?.slice(1) || 'default',

    gc: true,

    });

    });

    server.listen(4444, () => {

    console.log('y-websocket server running on port 4444');

    });

    Step 4: Build the Collaborative Editor Component

    Create client/src/CollaborativeEditor.tsx:

    import { useEditor, EditorContent, BubbleMenu } 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 { WebsocketProvider } from 'y-websocket';

    import { useEffect, useMemo, useState } from 'react';

    // Random color generator for cursor colors

    const colors = [

    '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',

    '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',

    ];

    const getRandomColor = () => colors[Math.floor(Math.random() * colors.length)];

    const getRandomName = () => {

    const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank'];

    return names[Math.floor(Math.random() * names.length)];

    };

    interface CollaborativeEditorProps {

    roomName: string;

    }

    export default function CollaborativeEditor({ roomName }: CollaborativeEditorProps) {

    const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');

    const [userCount, setUserCount] = useState(0);

    // Create Y.js document and WebSocket provider

    const { ydoc, provider } = useMemo(() => {

    const ydoc = new Y.Doc();

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    // Set user awareness info

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider };

    }, [roomName]);

    // Track connection status

    useEffect(() => {

    const onStatus = ({ status: s }: { status: string }) => {

    setStatus(s as any);

    };

    provider.on('status', onStatus);

    const onAwarenessChange = () => {

    setUserCount(provider.awareness.getStates().size);

    };

    provider.awareness.on('change', onAwarenessChange);

    return () => {

    provider.off('status', onStatus);

    provider.awareness.off('change', onAwarenessChange);

    provider.destroy();

    ydoc.destroy();

    };

    }, [provider, ydoc]);

    // Initialize TipTap editor with collaboration extensions

    const editor = useEditor({

    extensions: [

    StarterKit.configure({

    // Disable default history — Y.js handles undo/redo

    history: false,

    }),

    Collaboration.configure({

    document: ydoc,

    }),

    CollaborationCursor.configure({

    provider,

    user: provider.awareness.getLocalState()?.user,

    }),

    ],

    editorProps: {

    attributes: {

    class:

    'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-6',

    },

    },

    }, [ydoc, provider]);

    return (

    <div className="collaborative-editor">

    {/ Status bar /}

    <div className="flex items-center justify-between p-3 bg-gray-100 rounded-t-lg border border-b-0">

    <div className="flex items-center gap-2">

    <div

    className={w-2 h-2 rounded-full ${

    status === 'connected'

    ? 'bg-green-500'

    : status === 'connecting'

    ? 'bg-yellow-500'

    : 'bg-red-500'

    }}

    />

    <span className="text-sm text-gray-600 capitalize">{status}</span>

    </div>

    <span className="text-sm text-gray-500">

    {userCount} user{userCount !== 1 ? 's' : ''} online

    </span>

    </div>

    {/ Toolbar /}

    {editor && (

    <div className="flex gap-1 p-2 border border-b-0 bg-white">

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBold().run()}

    active={editor.isActive('bold')}

    >

    B

    </ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleItalic().run()}

    active={editor.isActive('italic')}

    >

    I

    </ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleStrike().run()}

    active={editor.isActive('strike')}

    >

    S

    </ToolbarButton>

    <span className="w-px bg-gray-300 mx-1" />

    <ToolbarButton

    onClick={() =>

    editor.chain().focus().toggleHeading({ level: 1 }).run()

    }

    active={editor.isActive('heading', { level: 1 })}

    >

    H1

    </ToolbarButton>

    <ToolbarButton

    onClick={() =>

    editor.chain().focus().toggleHeading({ level: 2 }).run()

    }

    active={editor.isActive('heading', { level: 2 })}

    >

    H2

    </ToolbarButton>

    <span className="w-px bg-gray-300 mx-1" />

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleBulletList().run()}

    active={editor.isActive('bulletList')}

    >

    • List

    </ToolbarButton>

    <ToolbarButton

    onClick={() => editor.chain().focus().toggleCodeBlock().run()}

    active={editor.isActive('codeBlock')}

    >

    Code

    </ToolbarButton>

    </div>

    )}

    {/ Editor /}

    <div className="border rounded-b-lg">

    <EditorContent editor={editor} />

    </div>

    </div>

    );

    }

    function ToolbarButton({

    onClick,

    active,

    children,

    }: {

    onClick: () => void;

    active: boolean;

    children: React.ReactNode;

    }) {

    return (

    <button

    onClick={onClick}

    className={px-3 py-1 rounded text-sm font-medium transition-colors ${

    active

    ? 'bg-blue-100 text-blue-700'

    : 'hover:bg-gray-100 text-gray-700'

    }}

    >

    {children}

    </button>

    );

    }

    Step 5: Style the Collaborative Cursors

    Create client/src/editor.css to style remote cursors:

    / Remote user cursors /
    

    .collaboration-cursor__caret {

    border-left: 2px solid;

    border-right: none;

    margin-left: -1px;

    margin-right: -1px;

    pointer-events: none;

    position: relative;

    word-break: normal;

    }

    / Cursor label (username) /

    .collaboration-cursor__label {

    border-radius: 3px 3px 3px 0;

    color: white;

    font-size: 11px;

    font-weight: 600;

    left: -1px;

    line-height: 1;

    padding: 2px 6px;

    position: absolute;

    top: -1.4em;

    user-select: none;

    white-space: nowrap;

    }

    / Selection highlight for remote users /

    .ProseMirror .selection {

    display: inline;

    }

    .ProseMirror p.is-editor-empty:first-child::before {

    color: #adb5bd;

    content: attr(data-placeholder);

    float: left;

    height: 0;

    pointer-events: none;

    }

    Step 6: Wire Up the App

    Update client/src/App.tsx:

    import { useState } from 'react';
    

    import CollaborativeEditor from './CollaborativeEditor';

    import './editor.css';

    export default function App() {

    const [roomName, setRoomName] = useState('my-document');

    const [joined, setJoined] = useState(false);

    if (!joined) {

    return (

    <div className="min-h-screen flex items-center justify-center bg-gray-50">

    <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">

    <h1 className="text-2xl font-bold mb-6 text-center">

    Collaborative Editor

    </h1>

    <input

    type="text"

    value={roomName}

    onChange={(e) => setRoomName(e.target.value)}

    placeholder="Enter room name..."

    className="w-full px-4 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500 outline-none"

    />

    <button

    onClick={() => setJoined(true)}

    className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition"

    >

    Join Room

    </button>

    </div>

    </div>

    );

    }

    return (

    <div className="min-h-screen bg-gray-50 p-8">

    <div className="max-w-4xl mx-auto">

    <div className="flex justify-between items-center mb-6">

    <h1 className="text-2xl font-bold">Room: {roomName}</h1>

    <button

    onClick={() => setJoined(false)}

    className="text-gray-500 hover:text-gray-700"

    >

    Leave Room

    </button>

    </div>

    <CollaborativeEditor roomName={roomName} />

    </div>

    </div>

    );

    }

    Step 7: Add Offline Support

    Y.js supports offline editing natively. Add IndexedDB persistence so changes survive browser refreshes:

    cd client
    

    npm install y-indexeddb

    Update the provider setup in CollaborativeEditor.tsx:

    import { IndexeddbPersistence } from 'y-indexeddb';
    
    

    // Inside the useMemo:

    const { ydoc, provider, indexeddbProvider } = useMemo(() => {

    const ydoc = new Y.Doc();

    // Persist to IndexedDB for offline support

    const indexeddbProvider = new IndexeddbPersistence(roomName, ydoc);

    indexeddbProvider.on('synced', () => {

    console.log('Loaded from IndexedDB');

    });

    // Connect to WebSocket server

    const provider = new WebsocketProvider(

    'ws://localhost:4444',

    roomName,

    ydoc,

    { connect: true }

    );

    provider.awareness.setLocalStateField('user', {

    name: getRandomName(),

    color: getRandomColor(),

    });

    return { ydoc, provider, indexeddbProvider };

    }, [roomName]);

    Now when a user goes offline, they can continue editing. When they reconnect, Y.js automatically merges all changes — no conflicts.

    Step 8: Undo/Redo with Y.UndoManager

    Y.js provides collaborative-aware undo/redo. Only the current user's changes are undone:

    import { UndoManager } from 'yjs';
    
    

    // After editor creation:

    const type = ydoc.getXmlFragment('default');

    const undoManager = new UndoManager(type, {

    trackedOrigins: new Set([null]),

    });

    // Add keyboard shortcuts in TipTap:

    import { Extension } from '@tiptap/core';

    const YjsUndo = Extension.create({

    name: 'yjsUndo',

    addKeyboardShortcuts() {

    return {

    'Mod-z': () => {

    undoManager.undo();

    return true;

    },

    'Mod-Shift-z': () => {

    undoManager.redo();

    return true;

    },

    };

    },

    });

    Step 9: Production Deployment

    Dockerize the Server

    Create server/Dockerfile:

    FROM node:20-alpine
    

    WORKDIR /app

    COPY package*.json ./

    RUN npm ci --production

    COPY dist/ ./dist/

    EXPOSE 4444

    VOLUME /app/docs

    CMD ["node", "dist/index.js"]

    Docker Compose

    version: '3.8'
    

    services:

    server:

    build: ./server

    ports:

    - '4444:4444'

    volumes:

    - doc-data:/app/docs

    restart: unless-stopped

    client:

    build: ./client

    ports:

    - '3000:80'

    depends_on:

    - server

    volumes:

    doc-data:

    Nginx Reverse Proxy

    upstream ws_backend {
    

    server localhost:4444;

    }

    server {

    listen 443 ssl;

    server_name collab.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/collab.yourdomain.com/fullchain.pem;

    ssl_certificate_key /etc/letsencrypt/live/collab.yourdomain.com/privkey.pem;

    location /ws {

    proxy_pass http://ws_backend;

    proxy_http_version 1.1;

    proxy_set_header Upgrade $http_upgrade;

    proxy_set_header Connection "upgrade";

    proxy_set_header Host $host;

    proxy_read_timeout 86400;

    }

    location / {

    root /var/www/collab-editor;

    try_files $uri $uri/ /index.html;

    }

    }

    Step 10: Scaling Considerations

    For scaling beyond a single server:

    1. Redis Pub/Sub: Use y-redis to sync document updates across multiple server instances

    2. Database Persistence: Replace file persistence with PostgreSQL or MongoDB using y-leveldb or a custom adapter

    3. Authentication: Add JWT verification in the WebSocket connection handler

    4. Rate Limiting: Limit update frequency per client to prevent abuse

    // Example: JWT auth on WebSocket connection
    

    import jwt from 'jsonwebtoken';

    wss.on('connection', (ws, req) => {

    const url = new URL(req.url!, http://${req.headers.host});

    const token = url.searchParams.get('token');

    try {

    const user = jwt.verify(token!, process.env.JWT_SECRET!);

    // Proceed with connection

    } catch {

    ws.close(4401, 'Unauthorized');

    return;

    }

    });

    Summary

    You've built a complete real-time collaborative editor with:

    | Feature | Technology |

    |---------|------------|

    | CRDT Document Model | Y.js |

    | Real-time Sync | WebSocket + y-websocket |

    | Rich Text Editor | TipTap (ProseMirror) |

    | Shared Cursors | Awareness Protocol |

    | Offline Support | y-indexeddb |

    | Persistence | File system / LevelDB |

    | Undo/Redo | Y.UndoManager |

    Y.js makes collaborative editing surprisingly approachable. The CRDT approach eliminates conflict resolution complexity, and the ecosystem of providers (WebSocket, WebRTC, IndexedDB) gives you flexibility in how you deploy. Start with this foundation and extend it with authentication, permissions, comments, and version history to build a production-grade collaborative platform.

    Further Reading

  • Y.js Documentation
  • TipTap Collaboration Guide
  • CRDT Primer by Martin Kleppmann
  • y-websocket GitHub