'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
UnifiedProviderinterface. - Awareness & Cursors: Integrates Yjs Awareness protocol for sharing cursor locations and other ephemeral state between users. Includes
RemoteCursorOverlayfor rendering remote cursors. - Customizable Cursors: Cursor appearance (name, color) can be customized via
cursors. - Manual Lifecycle: Provides explicit
initanddestroymethods 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: AssignsRemoteCursorOverlayto 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
wsOptions?: HocuspocusProviderWebsocketConfiguration; // Advanced websocket config (headers, protocols, etc.)
}
}wsOptions
You can pass a wsOptions field to configure advanced websocket options for the Hocuspocus provider. This is useful for custom headers, authentication, protocols, or other websocket settings supported by HocuspocusProviderWebsocket.
Example usage:
{
type: 'hocuspocus',
options: {
name: 'my-document-id',
},
wsOptions: {
url: 'ws://localhost:8888',
maxAttempts: 5,
parameters: {
// request parameters
}
},
}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
onAuthenticatehook to validate users - Implement document-level access control on your backend
- Pass authentication tokens via the
tokenoption
Transport Security:
- Use
wss://URLs in production for encrypted communication - Configure secure TURN servers with the
turns://protocol
WebRTC Security:
- Use the
passwordoption 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) andsignalingURLs (WebRTC) are correct - Ensure
nameorroomNamematches 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.Docinstances for each document - Use unique document identifiers for
name/roomName - Pass unique
ydocandawarenessinstances to each editor
Sync Issues
Editor Initialization:
- Always set
skipInitialization: truewhen 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
RemoteCursorOverlayin plugin render config - Use positioned container (
EditorContainerorPlateContainer) - Verify
cursors.data(name, color) is set correctly for local user
Related
- Yjs - CRDT framework for collaboration
- slate-yjs - Yjs bindings for Slate
- Hocuspocus - Backend server for Yjs
- y-webrtc - WebRTC provider
- RemoteCursorOverlay - Remote cursor component
- EditorContainer - Editor container component
Plugins
YjsPlugin
Enables real-time collaboration using Yjs with support for multiple providers and remote cursors.
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.
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.
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.
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.
api.yjs.disconnect
Manually disconnects from providers.
On This Page
FeaturesUsageInstallationAdd PluginConfigure YjsPluginAdd Editor ContainerInitialize Yjs ConnectionMonitor Connection Status (Optional)Provider TypesHocuspocus ProviderwsOptionsWebRTC ProviderCustom ProviderBackend SetupHocuspocus ServerWebRTC SetupSignaling ServerTURN ServersSecurityTroubleshootingConnection IssuesMultiple DocumentsSync IssuesCursor IssuesRelatedPluginsYjsPluginAPIapi.yjs.initapi.yjs.destroyapi.yjs.connectapi.yjs.disconnect