Collaboration

Real-time collaboration with Yjs

Loading...
Files
components/collaboration-demo.tsx
'use client';

import * as React from 'react';

import { YjsPlugin } from '@platejs/yjs/react';
import { RefreshCw } from 'lucide-react';
import { nanoid } from 'nanoid';
import {
  Plate,
  useEditorRef,
  usePlateEditor,
  usePluginOption,
} from 'platejs/react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EditorKit } from '@/components/editor/editor-kit';
import { useMounted } from '@/hooks/use-mounted';
import { Editor, EditorContainer } from '@/components/ui/editor';
import { RemoteCursorOverlay } from '@/components/ui/remote-cursor-overlay';

const INITIAL_VALUE = [
  {
    children: [{ text: 'This is the initial content loaded into the Y.Doc.' }],
    type: 'p',
  },
];

export default function CollaborativeEditingDemo(): React.ReactNode {
  const mounted = useMounted();
  const { generateNewRoom, roomName, handleRoomChange } =
    useCollaborationRoom();
  const { cursorColor, username } = useCollaborationUser();

  const editor = usePlateEditor(
    {
      plugins: [
        ...EditorKit,
        YjsPlugin.configure({
          options: {
            cursors: {
              data: { color: cursorColor, name: username },
            },
            providers: [
              {
                options: {
                  name: roomName,
                  url: 'ws://localhost:8888',
                },
                type: 'hocuspocus',
              },
              {
                options: {
                  maxConns: 9, // Limit to 10 total participants
                  roomName: roomName,
                  signaling: [
                    process.env.NODE_ENV === 'production'
                      ? // Use public signaling server just for demo purposes
                        'wss://signaling.yjs.dev'
                      : 'ws://localhost:4444',
                  ],
                },
                type: 'webrtc',
              },
            ],
          },
          render: {
            afterEditable: RemoteCursorOverlay,
          },
        }),
      ],
      skipInitialization: true,
    },
    [roomName]
  );

  React.useEffect(() => {
    if (!mounted) return;

    editor.getApi(YjsPlugin).yjs.init({
      id: roomName,
      autoSelect: 'end',
      value: INITIAL_VALUE,
    });

    return () => {
      editor.getApi(YjsPlugin).yjs.destroy();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [editor, mounted]);

  return (
    <div className="flex flex-col">
      <div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
        <div className="flex items-center gap-2">
          <div className="flex-1">
            <label className="mb-1 block text-xs font-medium" htmlFor="room-id">
              Room ID (share this to collaborate)
            </label>
            <div className="flex items-center gap-2">
              <Input
                id="room-id"
                className="h-[28px] bg-background px-1.5 py-1"
                value={roomName}
                onChange={handleRoomChange}
                type="text"
              />
              <Button
                size="icon"
                variant="outline"
                onClick={generateNewRoom}
                title="Generate new room"
              >
                <RefreshCw className="h-4 w-4" />
              </Button>
            </div>
          </div>
        </div>
        <p className="mt-2">
          You can{' '}
          <a
            className="underline underline-offset-4 transition-colors hover:text-primary"
            href={typeof window === 'undefined' ? '#' : window.location.href}
            rel="noopener noreferrer"
            target="_blank"
          >
            open this page in another tab
          </a>{' '}
          or share your Room ID with others to test real-time collaboration.
          Each instance will have a different cursor color for easy
          identification.
        </p>
        <div className="mt-2">
          <strong>About this demo:</strong>
          <ul className="mt-1 list-inside list-disc">
            <li>
              Share your Room ID with others to collaborate in the same document
            </li>
            <li>Limited to 10 concurrent participants per room</li>
            <li>
              Using WebRTC with public signaling servers - for demo purposes
              only
            </li>
          </ul>
        </div>
      </div>

      <div className="flex-1 overflow-hidden border-t">
        <Plate editor={editor}>
          <CollaborativeEditor cursorColor={cursorColor} username={username} />
        </Plate>
      </div>
    </div>
  );
}

function CollaborativeEditor({
  cursorColor,
  username,
}: {
  cursorColor: string;
  username: string;
}): React.ReactNode {
  const editor = useEditorRef();
  const providers = usePluginOption(YjsPlugin, '_providers');
  const isConnected = usePluginOption(YjsPlugin, '_isConnected');

  const toggleConnection = () => {
    if (editor.getOptions(YjsPlugin)._isConnected) {
      return editor.getApi(YjsPlugin).yjs.disconnect();
    }

    editor.getApi(YjsPlugin).yjs.connect();
  };

  return (
    <>
      <div className="bg-muted px-4 py-2 font-medium">
        Connected as <span style={{ color: cursorColor }}>{username}</span>
        <div className="mt-1 flex items-center gap-2 text-xs">
          {providers.map((provider) => (
            <span
              key={provider.type}
              className={`rounded px-2 py-0.5 ${
                provider.isConnected
                  ? 'bg-green-100 text-green-800'
                  : 'bg-red-100 text-red-800'
              }`}
            >
              {provider.type.charAt(0).toUpperCase() + provider.type.slice(1)}:{' '}
              {provider.isConnected ? 'Connected' : 'Disconnected'}
            </span>
          ))}
          <Button
            size="sm"
            variant="outline"
            className="ml-auto"
            onClick={toggleConnection}
          >
            {isConnected ? 'Disconnect' : 'Connect'}
          </Button>
        </div>
      </div>

      <EditorContainer variant="demo">
        <Editor autoFocus />
      </EditorContainer>
    </>
  );
}

// Hook for managing room state
function useCollaborationRoom() {
  const [roomName, setRoomName] = React.useState(() => {
    if (typeof window === 'undefined') return '';

    const storedRoomId = localStorage.getItem('demo-room-id');
    if (storedRoomId) return storedRoomId;

    const newRoomId = nanoid();
    localStorage.setItem('demo-room-id', newRoomId);
    return newRoomId;
  });

  const handleRoomChange = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newRoomId = e.target.value;
      localStorage.setItem('demo-room-id', newRoomId);
      setRoomName(newRoomId);
    },
    []
  );

  const generateNewRoom = React.useCallback(() => {
    const newRoomId = nanoid();
    localStorage.setItem('demo-room-id', newRoomId);
    setRoomName(newRoomId);
  }, []);

  return {
    generateNewRoom,
    roomName,
    handleRoomChange,
  };
}

// Hook for managing user/cursor state
function useCollaborationUser() {
  const [username] = React.useState(
    () => `user-${Math.floor(Math.random() * 1000)}`
  );
  const [cursorColor] = React.useState(() => getRandomColor());

  return {
    cursorColor,
    username,
  };
}

const getRandomColor = (): string => {
  const letters = '0123456789ABCDEF';
  let color = '#';
  for (let i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
};

Features

  • Multi-Provider Support: Enables real-time collaboration using Yjs and slate-yjs. Supports multiple synchronization providers simultaneously (e.g., Hocuspocus + WebRTC) working on a shared Y.Doc.
  • Built-in Providers: Includes support for Hocuspocus (server-based) and WebRTC (peer-to-peer) providers out-of-the-box.
  • Custom Providers: Extensible architecture allows adding custom providers (e.g., for offline storage like IndexedDB) by implementing the UnifiedProvider interface.
  • Awareness & Cursors: Integrates Yjs Awareness protocol for sharing cursor locations and other ephemeral state between users. Includes RemoteCursorOverlay for rendering remote cursors.
  • Customizable Cursors: Cursor appearance (name, color) can be customized via cursors.
  • Manual Lifecycle: Provides explicit init and destroy methods for managing the Yjs connection lifecycle.

Usage

Installation

Install the core Yjs plugin and the specific provider packages you intend to use:

pnpm add @platejs/yjs

For Hocuspocus server-based collaboration:

pnpm add @hocuspocus/provider

For WebRTC peer-to-peer collaboration:

pnpm add y-webrtc

Add Plugin

import { YjsPlugin } from '@platejs/yjs/react';
import { createPlateEditor } from 'platejs/react';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    YjsPlugin,
  ],
  // Important: Skip Plate's default initialization when using Yjs
  skipInitialization: true,
});

Required Editor Configuration

It's crucial to set skipInitialization: true when creating the editor. Yjs manages the initial document state, so Plate's default value initialization should be skipped to avoid conflicts.

Configure YjsPlugin

Configure the plugin with providers and cursor settings:

import { YjsPlugin } from '@platejs/yjs/react';
import { createPlateEditor } from 'platejs/react';
import { RemoteCursorOverlay } from '@/components/ui/remote-cursor-overlay';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    YjsPlugin.configure({
      render: {
        afterEditable: RemoteCursorOverlay,
      },
      options: {
        // Configure local user cursor appearance
        cursors: {
          data: {
            name: 'User Name', // Replace with dynamic user name
            color: '#aabbcc', // Replace with dynamic user color
          },
        },
        // Configure providers. All providers share the same Y.Doc and Awareness instance.
        providers: [
          // Example: Hocuspocus provider
          {
            type: 'hocuspocus',
            options: {
              name: 'my-document-id', // Unique identifier for the document
              url: 'ws://localhost:8888', // Your Hocuspocus server URL
            },
          },
          // Example: WebRTC provider (can be used alongside Hocuspocus)
          {
            type: 'webrtc',
            options: {
              roomName: 'my-document-id', // Must match the document identifier
              signaling: ['ws://localhost:4444'], // Optional: Your signaling server URLs
            },
          },
        ],
      },
    }),
  ],
  skipInitialization: true,
});
  • render.afterEditable: Assigns RemoteCursorOverlay to render remote user cursors.
  • cursors.data: Configures the local user's cursor appearance with name and color.
  • providers: Array of collaboration providers to use (Hocuspocus, WebRTC, or custom providers).

Add Editor Container

The RemoteCursorOverlay requires a positioned container around the editor content. Use EditorContainer component or PlateContainer from platejs/react:

import { Plate } from 'platejs/react';
import { EditorContainer } from '@/components/ui/editor';
 
return (
  <Plate editor={editor}>
    <EditorContainer>
      <Editor />
    </EditorContainer>
  </Plate>
);

Initialize Yjs Connection

Yjs connection and state initialization are handled manually, typically within a useEffect hook:

import React, { useEffect } from 'react';
import { YjsPlugin } from '@platejs/yjs/react';
import { useMounted } from '@/hooks/use-mounted'; // Or your own mounted check
 
const MyEditorComponent = ({ documentId, initialValue }) => {
  const editor = usePlateEditor(/** editor config from previous steps **/);
  const mounted = useMounted();
 
  useEffect(() => {
    // Ensure component is mounted and editor is ready
    if (!mounted) return;
 
    // Initialize Yjs connection, sync document, and set initial editor state
    editor.getApi(YjsPlugin).yjs.init({
      id: documentId,          // Unique identifier for the Yjs document
      value: initialValue,     // Initial content if the Y.Doc is empty
    });
 
    // Clean up: Destroy connection when component unmounts
    return () => {
      editor.getApi(YjsPlugin).yjs.destroy();
    };
  }, [editor, mounted]);
 
  return (
    <Plate editor={editor}>
      <EditorContainer>
        <Editor />
      </EditorContainer>
    </Plate>
  );
};

Initial Value: The value passed to init is only used to populate the Y.Doc if it's completely empty on the backend/peer network. If the document already exists, its content will be synced, and this initial value will be ignored.

Lifecycle Management: You must call editor.api.yjs.init() to establish the connection and editor.api.yjs.destroy() on component unmount to clean up resources.

Monitor Connection Status (Optional)

Access provider states and add event handlers for connection monitoring:

import React from 'react';
import { YjsPlugin } from '@platejs/yjs/react';
import { usePluginOption } from 'platejs/react';
 
function EditorStatus() {
  // Access provider states directly (read-only)
  const providers = usePluginOption(YjsPlugin, '_providers');
  const isConnected = usePluginOption(YjsPlugin, '_isConnected');
 
  return (
    <div>
      {providers.map((provider) => (
        <span key={provider.type}>
          {provider.type}: {provider.isConnected ? 'Connected' : 'Disconnected'} ({provider.isSynced ? 'Synced' : 'Syncing'})
        </span>
      ))}
    </div>
  );
}
 
// Add event handlers for connection events:
YjsPlugin.configure({
  options: {
    // ... other options
    onConnect: ({ type }) => console.debug(`Provider ${type} connected!`),
    onDisconnect: ({ type }) => console.debug(`Provider ${type} disconnected.`),
    onSyncChange: ({ type, isSynced }) => console.debug(`Provider ${type} sync status: ${isSynced}`),
    onError: ({ type, error }) => console.error(`Error in provider ${type}:`, error),
  },
});

Provider Types

Hocuspocus Provider

Server-based collaboration using Hocuspocus. Requires a running Hocuspocus server.

type HocuspocusProviderConfig = {
  type: 'hocuspocus',
  options: {
    name: string;     // Document identifier
    url: string;      // WebSocket server URL
    token?: string;   // Authentication token
  }
}

WebRTC Provider

Peer-to-peer collaboration using y-webrtc.

type WebRTCProviderConfig = {
  type: 'webrtc',
  options: {
    roomName: string;      // Room name for collaboration
    signaling?: string[];  // Signaling server URLs
    password?: string;     // Room password
    maxConns?: number;     // Max connections
    peerOpts?: object;     // WebRTC peer options
  }
}

Custom Provider

Create custom providers by implementing the UnifiedProvider interface:

interface UnifiedProvider {
  awareness: Awareness;
  document: Y.Doc;
  type: string;
  connect: () => void;
  destroy: () => void;
  disconnect: () => void;
  isConnected: boolean;
  isSynced: boolean;
}

Use custom providers directly in the providers array:

const customProvider = new MyCustomProvider({ doc: ydoc, awareness });
 
YjsPlugin.configure({
  options: {
    providers: [customProvider],
  },
});

Backend Setup

Hocuspocus Server

Set up a Hocuspocus server for server-based collaboration. Ensure the url and name in your provider options match your server configuration.

WebRTC Setup

Signaling Server

WebRTC requires signaling servers for peer discovery. Public servers work for testing but use your own for production:

pnpm add y-webrtc
PORT=4444 node ./node_modules/y-webrtc/bin/server.js

Configure your client to use custom signaling:

{
  type: 'webrtc',
  options: {
    roomName: 'document-1',
    signaling: ['ws://your-signaling-server.com:4444'],
  },
}

TURN Servers

WebRTC connections can fail due to firewalls. Use TURN servers or combine with Hocuspocus for production reliability.

Configure TURN servers for reliable connections:

{
  type: 'webrtc',
  options: {
    roomName: 'document-1',
    signaling: ['ws://your-signaling-server.com:4444'],
    peerOpts: {
      config: {
        iceServers: [
          { urls: 'stun:stun.l.google.com:19302' },
          {
            urls: 'turn:your-turn-server.com:3478',
            username: 'username',
            credential: 'password'
          }
        ]
      }
    }
  }
}

Security

Authentication & Authorization:

  • Use Hocuspocus's onAuthenticate hook to validate users
  • Implement document-level access control on your backend
  • Pass authentication tokens via the token option

Transport Security:

  • Use wss:// URLs in production for encrypted communication
  • Configure secure TURN servers with the turns:// protocol

WebRTC Security:

  • Use the password option for basic room access control
  • Configure secure signaling servers

Example secure configuration:

YjsPlugin.configure({
  options: {
    providers: [
      {
        type: 'hocuspocus',
        options: {
          name: 'secure-document-id',
          url: 'wss://your-hocuspocus-server.com',
          token: 'user-auth-token',
        },
      },
      {
        type: 'webrtc',
        options: {
          roomName: 'secure-document-id',
          password: 'strong-room-password',
          signaling: ['wss://your-secure-signaling.com'],
          peerOpts: {
            config: {
              iceServers: [
                {
                  urls: 'turns:your-turn-server.com:443?transport=tcp',
                  username: 'user',
                  credential: 'pass'
                }
              ]
            }
          }
        },
      },
    ],
  },
});

Troubleshooting

Connection Issues

Check URLs and Names:

  • Verify url (Hocuspocus) and signaling URLs (WebRTC) are correct
  • Ensure name or roomName matches exactly across all collaborators
  • Use ws:// for local development, wss:// for production

Server Status:

  • Verify Hocuspocus and signaling servers are running
  • Check server logs for errors
  • Test TURN server connectivity if using WebRTC

Network Issues:

  • Firewalls may block WebSocket or WebRTC traffic
  • Use TURN servers configured for TCP (port 443) for better traversal
  • Check browser console for provider errors

Multiple Documents

Separate Instances:

  • Create separate Y.Doc instances for each document
  • Use unique document identifiers for name/roomName
  • Pass unique ydoc and awareness instances to each editor

Sync Issues

Editor Initialization:

  • Always set skipInitialization: true when creating the editor
  • Use editor.api.yjs.init({ value }) for initial content
  • Ensure all providers use the exact same document identifier

Content Conflicts:

  • Avoid manually manipulating the shared Y.Doc
  • Let Yjs handle all document operations through the editor

Cursor Issues

Overlay Setup:

  • Include RemoteCursorOverlay in plugin render config
  • Use positioned container (EditorContainer or PlateContainer)
  • Verify cursors.data (name, color) is set correctly for local user

Plugins

YjsPlugin

Enables real-time collaboration using Yjs with support for multiple providers and remote cursors.

Options

Collapse all

    Array of provider configurations or pre-instantiated provider instances. The plugin will create instances from configurations and use existing instances directly. All providers will share the same Y.Doc and Awareness. Each configuration object specifies a provider type (e.g., 'hocuspocus', 'webrtc') and its specific options. Custom provider instances must conform to the UnifiedProvider interface.

    Configuration for remote cursors. Set to null to explicitly disable cursors. If omitted, cursors are enabled by default if providers are specified. Passed to withTCursors. See WithCursorsOptions API. Includes data for local user info and autoSend (default true).

    Optional shared Y.Doc instance. If not provided, a new one will be created internally by the plugin. Provide your own if integrating with other Yjs tools or managing multiple documents.

    Optional shared Awareness instance. If not provided, a new one will be created.

    Callback fired when any provider successfully connects.

    Callback fired when any provider disconnects.

    Callback fired when any provider encounters an error (e.g., connection failure).

    Callback fired when the sync status (provider.isSynced) of any individual provider changes.

Attributes

Collapse all

    Internal state: Whether at least one provider is currently connected.

    Internal state: Reflects overall sync status.

    Internal state: Array of all active, instantiated provider instances.

API

api.yjs.init

Initializes the Yjs connection, binds it to the editor, sets up providers based on plugin configuration, potentially populates the Y.Doc with initial content, and connects providers. Must be called after the editor is mounted.

Parameters

Collapse all

    Configuration object for initialization.

Optionsobject

Collapse all

    A unique identifier for the Yjs document (e.g., room name, document ID). If not provided, editor.id is used. Essential for ensuring collaborators connect to the same document state.

    The initial content for the editor. This is only applied if the Y.Doc associated with the id is completely empty in the shared state (backend/peers). If the document already exists, its content will be synced, ignoring this value. Can be Plate JSON (Value), an HTML string, or a function returning/resolving to Value. If omitted or empty, a default empty paragraph is used for initialization if the Y.Doc is new.

    Whether to automatically call provider.connect() for all configured providers during initialization. Default: true. Set to false if you want to manage connections manually using editor.api.yjs.connect().

    If set, automatically focuses the editor and places the cursor at the 'start' or 'end' of the document after initialization and sync.

    Specific Plate Location to set the selection to after initialization, overriding autoSelect.

ReturnsPromise<void>

    Resolves when the initial setup (including potential async value resolution and YjsEditor binding) is complete. Note that provider connection and synchronization happen asynchronously.

api.yjs.destroy

Disconnects all providers, cleans up Yjs bindings (detaches editor from Y.Doc), and destroys the awareness instance. Must be called when the editor component unmounts to prevent memory leaks and stale connections.

api.yjs.connect

Manually connects to providers. Useful if autoConnect: false was used during init.

Parameters

Collapse all

    If provided, only connects to providers of the specified type(s). If omitted, connects to all configured providers that are not already connected.

api.yjs.disconnect

Manually disconnects from providers.

Parameters

Collapse all

    If provided, only disconnects from providers of the specified type(s). If omitted, disconnects from all currently connected providers.