Skip to main content

Overview

The @yoopta/collaboration package enables real-time collaborative editing in Yoopta Editor. Multiple users can edit the same document simultaneously with automatic conflict resolution powered by Yjs CRDT. The package provides:
  • withCollaboration extension — connects your editor to a collaboration server
  • Collaborative undo/redo — Cmd+Z only undoes your own changes, not other users’
  • Remote cursors — see where other users are editing
  • Awareness — track connected users and their presence
  • React hooks — access collaboration state in your components
The collaboration client is fully open-source and works with any Yjs-compatible WebSocket server. You can self-host with your own infrastructure or use Yoopta Cloud for a managed solution with version history, permissions, and more.

Installation

npm install @yoopta/collaboration
@yoopta/collaboration requires @yoopta/editor >= 6.0.0-beta.19 and React >= 18.2.0 as peer dependencies.

Quick Start

Wrap your editor with withCollaboration — similar to how withMentions works:
import { useMemo, useEffect } from 'react';
import YooptaEditor, { createYooptaEditor } from '@yoopta/editor';
import { withCollaboration, RemoteCursors } from '@yoopta/collaboration';
import { PLUGINS } from './plugins';
import { MARKS } from './marks';

export default function CollaborativeEditor() {
  const editor = useMemo(
    () =>
      withCollaboration(
        createYooptaEditor({ plugins: PLUGINS, marks: MARKS }),
        {
          url: 'wss://collab.yoopta.cloud',
          roomId: 'document-123',
          token: 'your-auth-token',
          user: {
            id: 'user-1',
            name: 'Alice',
            color: '#e06c75',
          },
        },
      ),
    [],
  );

  useEffect(() => {
    return () => editor.collaboration.destroy();
  }, [editor]);

  return (
    <YooptaEditor
      editor={editor}
      onChange={(value) => console.log(value)}
    >
      <RemoteCursors />
      {/* Your other UI components */}
    </YooptaEditor>
  );
}
Always call editor.collaboration.destroy() on cleanup to properly close the WebSocket connection and remove awareness states.

Configuration

withCollaboration(editor, config)

Extends a YooEditor instance with collaboration capabilities. Returns a CollaborationYooEditor.
editor
YooEditor
required
The editor instance from createYooptaEditor().
config
CollaborationConfig
required
Configuration object for the collaboration session.

CollaborationConfig

url
string
required
WebSocket server URL. Example: "wss://collab.yoopta.cloud"
roomId
string
required
Unique identifier for the document/room. All users with the same roomId will collaborate on the same document.
user
CollaborationUser
required
Information about the current user. Used for cursor labels and presence indicators.
{
  id: string;       // Unique user identifier
  name: string;     // Display name (shown on cursors)
  color: string;    // Cursor color (hex, e.g. "#e06c75")
  avatar?: string;  // Optional avatar URL
}
token
string
Authentication token sent to the server on connection. Use this to verify users and check subscription access on the server side.
initialValue
YooptaContentValue
Initial content to seed the document if no remote state exists. When the first user connects to an empty room, this value populates the shared document.
connect
boolean
default:"true"
Whether to connect to the server immediately. Set to false to connect manually later via editor.collaboration.connect().
document
Y.Doc
Optional: provide your own Yjs Y.Doc instance. If not provided, one is created automatically. Useful for advanced integrations or testing.

Editor API

After applying withCollaboration, the editor gains an editor.collaboration namespace:

editor.collaboration.state

Read-only access to the current collaboration state.
type CollaborationState = {
  status: ConnectionStatus;           // 'disconnected' | 'connecting' | 'connected' | 'error'
  connectedUsers: CollaborationUser[]; // All users currently in the room
  document: Y.Doc | null;             // The Yjs document instance
  isSynced: boolean;                   // Whether initial sync with server is complete
};

editor.collaboration.connect()

Manually connect to the WebSocket server. Only needed if connect: false was passed in config.
editor.collaboration.connect();

editor.collaboration.disconnect()

Disconnect from the server. The editor remains usable for local editing.
editor.collaboration.disconnect();

editor.collaboration.destroy()

Disconnect and clean up all resources (WebSocket, observers, awareness). Call this on component unmount.
useEffect(() => {
  return () => editor.collaboration.destroy();
}, [editor]);

editor.collaboration.getDocument()

Returns the underlying Y.Doc instance for advanced use cases.
const doc = editor.collaboration.getDocument();

Components

<RemoteCursors />

Renders visual indicators showing which block each remote user is currently editing.
import { RemoteCursors } from '@yoopta/collaboration';

<YooptaEditor editor={editor}>
  <RemoteCursors />
</YooptaEditor>
Must be rendered as a child of <YooptaEditor>. Displays a colored bar and name label next to the block each remote user is editing.

Hooks

All hooks must be used within a <YooptaEditor> component that has collaboration enabled.

useCollaboration()

Returns the full collaboration state. Re-renders on any state change.
import { useCollaboration } from '@yoopta/collaboration';

function StatusBar() {
  const { status, connectedUsers, isSynced } = useCollaboration();

  return (
    <div>
      <span>{status}</span>
      <span>{connectedUsers.length} online</span>
      {!isSynced && <span>Syncing...</span>}
    </div>
  );
}

useConnectionStatus()

Returns only the connection status string. Lighter than useCollaboration() when you only need the status.
import { useConnectionStatus } from '@yoopta/collaboration';

function ConnectionIndicator() {
  const status = useConnectionStatus();

  return (
    <div style={{
      width: 8,
      height: 8,
      borderRadius: '50%',
      backgroundColor:
        status === 'connected' ? '#4ade80' :
        status === 'connecting' ? '#facc15' :
        '#ef4444',
    }} />
  );
}

useRemoteCursors()

Returns an array of remote cursor data for all connected users.
import { useRemoteCursors } from '@yoopta/collaboration';

function UserPresence() {
  const cursors = useRemoteCursors();

  return (
    <div>
      {cursors.map((cursor) => (
        <div key={cursor.clientId} style={{ color: cursor.user.color }}>
          {cursor.user.name} - editing block {cursor.blockId}
        </div>
      ))}
    </div>
  );
}

Deferred Connection

If you need to connect after the editor is rendered (e.g., after authentication), set connect: false:
const editor = useMemo(
  () =>
    withCollaboration(
      createYooptaEditor({ plugins: PLUGINS, marks: MARKS }),
      {
        url: 'wss://collab.yoopta.cloud',
        roomId: 'doc-123',
        user: { id: 'user-1', name: 'Alice', color: '#e06c75' },
        connect: false,  // Don't connect yet
      },
    ),
  [],
);

// Connect later, e.g. after auth
function handleLogin(token: string) {
  editor.collaboration.connect();
}

Custom Y.Doc

For advanced use cases (e.g., testing two editors in the same page), you can provide your own Y.Doc:
import * as Y from 'yjs';

const doc = new Y.Doc();

const editor = useMemo(
  () =>
    withCollaboration(
      createYooptaEditor({ plugins: PLUGINS, marks: MARKS }),
      {
        url: 'wss://collab.yoopta.cloud',
        roomId: 'doc-123',
        user: { id: 'user-1', name: 'Alice', color: '#e06c75' },
        document: doc,
      },
    ),
  [],
);

Self-Hosted Setup

@yoopta/collaboration uses Yjs only as a sync layer — it does not store your documents. Your content remains standard Yoopta JSON (YooptaContentValue) that you store in your own database.
┌─────────────┐     load       ┌──────────────────┐      sync       ┌─────────────┐
│ Your DB     │ ──────────────►│  Yoopta Editor    │◄───────────────►│  Other      │
│ (Postgres,  │  initialValue  │  + Y.Doc          │  WebSocket      │  Clients    │
│  Mongo, ..) │◄──────────────┤                    │  (y-protocols)  │             │
└─────────────┘  save on       └────────┬───────────┘                └─────────────┘
                 change                 │

                                 ┌──────▼──────────┐
                                 │  Your WebSocket  │
                                 │  Server          │
                                 └─────────────────┘
The Y.Doc is ephemeral — it only lives during the collaboration session. When all users disconnect, it can be discarded. Your database is the source of truth.

Persisting to Your Database

Load content from your database as initialValue, and save back on change:
import { useMemo, useEffect, useCallback } from 'react';
import { debounce } from 'lodash'; // or your preferred debounce
import YooptaEditor, { createYooptaEditor } from '@yoopta/editor';
import { withCollaboration, RemoteCursors } from '@yoopta/collaboration';

export default function CollaborativeEditor({ documentId, savedContent, currentUser }) {
  const editor = useMemo(
    () =>
      withCollaboration(
        createYooptaEditor({ plugins: PLUGINS, marks: MARKS }),
        {
          url: 'ws://localhost:4000',       // Your own server
          roomId: documentId,
          user: currentUser,
          initialValue: savedContent,        // Load from your DB
        },
      ),
    [],
  );

  // Save to your database on change
  const handleChange = useCallback(
    debounce((value) => {
      fetch(`/api/documents/${documentId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(value),
      });
    }, 2000),
    [documentId],
  );

  useEffect(() => {
    return () => editor.collaboration.destroy();
  }, [editor]);

  return (
    <YooptaEditor editor={editor} onChange={handleChange}>
      <RemoteCursors />
    </YooptaEditor>
  );
}
The initialValue is only used when the first user connects to an empty room. Once the Y.Doc has content (from any client), subsequent connections load from the Y.Doc state, not from initialValue.

Running Your Own WebSocket Server

Any Yjs-compatible WebSocket server works. The simplest option is y-websocket:
npm install y-websocket
// server.js
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';

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

wss.on('connection', (ws, req) => {
  // req.url is "/<roomId>"
  setupWSConnection(ws, req);
});

console.log('Yjs WebSocket server running on ws://localhost:4000');
For production, consider Hocuspocus which adds authentication, persistence hooks, and webhook support out of the box.
For small teams (2-5 concurrent editors), a single y-websocket process is more than sufficient. No special infrastructure needed.

Yoopta Cloud

For teams that don’t want to run their own collaboration infrastructure, Yoopta Cloud provides a managed solution:
  • Managed WebSocket infrastructure with automatic scaling
  • Built-in document persistence and version history
  • Authentication and room-level permissions
  • Comments and threads
  • REST API for document access
  • Webhooks for integrations (search indexing, backups, workflows)
  • Analytics dashboard
// Using Yoopta Cloud — just change the URL and add your API key
const editor = useMemo(
  () =>
    withCollaboration(
      createYooptaEditor({ plugins: PLUGINS, marks: MARKS }),
      {
        url: 'wss://collab.yoopta.cloud',
        roomId: 'document-123',
        token: 'your-api-key',
        user: { id: 'user-1', name: 'Alice', color: '#e06c75' },
      },
    ),
  [],
);
Yoopta Cloud is coming soon. The self-hosted path described above will always remain free and open-source.

How It Works

Under the hood, @yoopta/collaboration maintains a bidirectional binding between the Yoopta editor and a Yjs shared document:
┌──────────────────────┐        ┌──────────────────────┐
│   Yoopta Editor      │        │   Y.Doc              │
│                      │        │                      │
│  editor.children ◄──►│ binding│ Y.Array("blockOrder")│
│  block ordering      │◄──────►│ Y.Map("blockMeta")   │
│  block metadata      │        │ Y.Map("blockContents")│
│  Slate content       │        │                      │
└──────────────────────┘        └──────┬───────────────┘

                                       │ y-protocols (WebSocket)

                                ┌──────▼───────────────┐
                                │   Collab Server       │
                                └───────────────────────┘
  • Local changes flow through editor.applyTransforms and are automatically synced to the Y.Doc, then sent to the server via WebSocket.
  • Remote changes arrive via WebSocket, update the Y.Doc, and are applied to the editor without entering the local undo/redo history.
  • Conflict resolution is handled by the Yjs CRDT — concurrent edits are merged automatically at the character level.
  • Undo/redo uses Yjs UndoManager to selectively reverse only local changes (see below).

Undo / Redo

When collaboration is active, editor.undo() and editor.redo() (Cmd+Z / Cmd+Shift+Z) automatically use Yjs UndoManager instead of the default history stack. This means:
  • Only your changes are undone — if Alice types “Hello” and Bob types “World”, Alice pressing Cmd+Z removes only “Hello”. Bob’s text stays intact.
  • Works at all levels — text edits, block insertions, deletions, splits, merges, formatting, and metadata changes are all tracked.
  • No extra setup neededwithCollaboration overrides undo/redo automatically. When collaboration is destroyed, the default history behavior is restored.
Rapid edits (e.g., continuous typing) are batched into a single undo step, similar to how undo works in Google Docs or Notion.
The initial document content (loaded from the server or seeded via initialValue) is not undoable — only edits made after the document loads can be undone.

TypeScript

All types are exported from the package:
import type {
  CollaborationConfig,
  CollaborationUser,
  CollaborationState,
  ConnectionStatus,
  RemoteCursorData,
  CollaborationAPI,
  CollaborationYooEditor,
} from '@yoopta/collaboration';

WebSocketProvider

For advanced server integrations, the WebSocketProvider class is exported:
import { WebSocketProvider } from '@yoopta/collaboration';
This is the transport layer used internally. It implements the Yjs sync and awareness protocols over WebSocket with automatic reconnection and authentication support.