'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
anddestroy
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
: AssignsRemoteCursorOverlay
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) andsignaling
URLs (WebRTC) are correct - Ensure
name
orroomName
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
andawareness
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
orPlateContainer
) - 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 ProviderWebRTC ProviderCustom ProviderBackend SetupHocuspocus ServerWebRTC SetupSignaling ServerTURN ServersSecurityTroubleshootingConnection IssuesMultiple DocumentsSync IssuesCursor IssuesRelatedPluginsYjsPluginAPIapi.yjs.initapi.yjs.destroyapi.yjs.connectapi.yjs.disconnect