Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Collaboration

PreviousNext

Real-time collaboration with Yjs

Loading…
ToolbarMulti Select

On This Page

FeaturesManual usageInstallationCreate the provider and readiness adapterAdd the Plate pluginAdd the copied cursor UIGate the editable on admissionPublish presence and control the providerOwnershipBackendsHocuspocusWebRTCIndexedDBTroubleshootingRelated
Build your editor
Production-ready AI template and reusable components.
Get all-access

The preview runs two independent editors through a credential-free local room. The room owns transport state and seeds its central Y.Doc before either editor connects. Each editor binds its own Y.Doc and awareness instance to Plate.

Features

  • App-owned transport: Your app owns Hocuspocus, WebSocket, WebRTC, IndexedDB, authentication, persistence, room names, and provider lifetime.
  • Explicit readiness: The binding admits edits only after the app's local or remote document source is ready.
  • Awareness and cursors: Yjs Awareness carries cursor locations and validated metadata. The copied cursor component renders selections, carets, and labels.
  • Multi-root documents: One Yjs transaction can synchronize primary children, named roots, and root-qualified selections.
  • Canonical history: Local and remote changes use the same Plate change path. Plate History records local commits without treating remote imports as local undo entries.
Report an issue

Manual usage

Installation

pnpm add platejs yjs
pnpm add platejs yjs

Install the provider package that your app uses. For example:

pnpm add @hocuspocus/provider
pnpm add @hocuspocus/provider

Create the provider and readiness adapter

Provider packages stay at the app boundary. The binding receives the provider's exact Y.Doc, an optional awareness instance for presence, and an explicit readiness source. It does not connect, disconnect, or destroy the provider.

This Hocuspocus adapter is local to the app because provider event contracts vary by package and version:

import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "platejs/yjs";
 
const createHocuspocusInitialReadiness = (
  provider: HocuspocusProvider
): YjsInitialReadiness => ({
  doc: provider.document,
  getSnapshot: () => provider.synced,
  subscribe(listener) {
    const onSynced = () => listener();
    provider.on("synced", onSynced);
 
    let active = true;
    return () =>





initialReady: true asserts that local or persistent loading for this document generation is already complete. It does not grant permission to initialize an empty shared room.

Add the Plate plugin

Every peer in a room must use the same compiled schema identity. Use a stable schema id and version for persistent rooms.

import { createEditor } from "platejs/react";
import { YjsPlugin } from "platejs/yjs/react";
 
const Collaboration = YjsPlugin.create({
  doc: provider.document,
  initialReady: createHocuspocusInitialReadiness(provider),
  awareness: provider.awareness,
  rootName: roomId,
  cursorData: { validate: isCollaborator },
});
 
const editor = createEditor({
  schema: { id: "yjs-example", version: 1 },
  plugins: [Collaboration],
  initialValue,
});

doc and initialReady are required. If awareness is present, its doc must be the same object as doc. Presence methods and cursor hooks are available only for bindings that include awareness.

For a normal server-backed room, persist a canonical Yjs update on the server and omit seed. Use seed: true only when the app has already selected one exclusive owner for a new empty room. A synchronized empty replica is not proof of seed ownership; multiple seeders can create duplicate content.

Add the copied cursor UI

pnpm dlx shadcn@latest add @plate/remote-cursor-overlay
pnpm dlx shadcn@latest add @plate/remote-cursor-overlay

The copied component exports a factory so each editor supplies its own binding resources and cursor-data validator:

import { CollaborationPlugin } from "@/components/editor/remote-cursor-overlay";
 
const Collaboration = CollaborationPlugin.create({
  doc: provider.document,
  initialReady: createHocuspocusInitialReadiness(provider),
  awareness: provider.awareness,
  rootName: roomId,
  cursorData: { validate: isCollaborator },
});
import { CollaborationPlugin } from "@/components/editor/remote-cursor-overlay";
 
const Collaboration = CollaborationPlugin.create





CollaborationPlugin maps selection styles and an afterEditable caret overlay onto each Yjs descriptor that it creates. Edit the copied file to customize colors, opacity, and labels.

Gate the editable on admission

Read provider connection state from the provider itself. Use the Yjs admission hook for the document binding state:

import { useYjsAdmissionStatus } from "platejs/yjs/react";
 
const status = useYjsAdmissionStatus(editor);
 
return (
  <Editor
    readOnly={status.state !== "ready"}
    aria-busy={status.state === "waiting"}
  />
);
import { useYjsAdmissionStatus } from "platejs/yjs/react";
 
const status






Render the error status in your application UI and offer a retry after its underlying cause is fixed:

if (status.state === "error") {
  return <button onClick={() => editor.api.yjs.retryImport()}>Retry</button>;
}
if (status.state === "error") {
  return <button onClick={() => editor.api.yjs.retryImport()}>Retry</button>;
}

The UI gate prevents confusing input. The binding also rejects document commits before publication while admission is waiting or failed.

Publish presence and control the provider

editor.api.yjs.setCursorData({ name: "Ada", color: "#7c3aed" });
 
const disconnect = () => {
  editor.api.yjs.clearSelection();
  provider.disconnect();
};
 
const reconnect = async () => {
  await provider.connect();
  if (editor.api.yjs.admissionStatus().state === "ready") {
    editor.api.yjs.syncSelection();
  }
};

Selection publication reads the current mounted editor view. clearSelection() withdraws that view's selection before a shared provider detaches. Provider status, errors, and cleanup remain app-owned. Initial admission publishes the current selection automatically; the explicit reconnect call applies to an already admitted binding.

Ownership

ConcernOwner
Y.Doc, readiness, and seed authorityApp
Provider connection, authentication, persistence, and cleanupApp
Document admission and Yjs translationplatejs/yjs
Cursor metadata and remote cursor snapshotsplatejs/yjs when awareness is supplied
Selection, editing, and undo/redoplatejs and platejs/history
Cursor presentationCopied remote-cursor-overlay component

Backends

Hocuspocus

Create HocuspocusProvider in app code, pass provider.document and provider.awareness to the plugin, and keep its server URL, token, room name, connection state, and destruction beside the provider.

WebRTC

Create the y-webrtc provider in app code and pass its exact document and awareness objects to the binding. Production signaling and TURN infrastructure remain app concerns.

IndexedDB

Use y-indexeddb to restore the Y.Doc before declaring initial readiness. IndexedDB does not provide remote awareness or cursor transport by itself.

Troubleshooting

  • Verify that awareness.doc === doc.
  • Verify every collaborator uses the same Yjs root name and Plate schema identity.
  • Keep provider connection and authentication errors visible in app UI or logs.
  • Give seed: true to one app-selected owner only.
  • Clear selection before detaching a view from a shared provider.
  • Render admission errors and call retryImport() only after fixing their cause.

Related

  • History
  • Yjs
  • Hocuspocus
  • y-webrtc
  • y-indexeddb
  • Collaboration example
  • Editor
{
if (!active) return;
active = false;
provider.off("synced", onSynced);
};
},
});
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { YjsInitialReadiness } from "platejs/yjs";
 
const createHocuspocusInitialReadiness = (
  provider: HocuspocusProvider
): YjsInitialReadiness => ({
  doc: provider.document,
  getSnapshot: () => provider.synced,
  subscribe(listener) {
    const onSynced = () => listener();
    provider.on("synced", onSynced);
 
    let active = true;
    return () => {
      if (!active) return;
      active = false;
      provider.off("synced", onSynced);
    };
  },
});
import { createEditor } from "platejs/react";
import { YjsPlugin } from "platejs/yjs/react";
 
const Collaboration = YjsPlugin.create({
  doc: provider.document,
  initialReady: createHocuspocusInitialReadiness(provider),
  awareness: provider.awareness,
  rootName: roomId,
  cursorData: { validate: isCollaborator },
});
 
const editor = createEditor({
  schema: { id: "yjs-example", version: 1 },
  plugins: [Collaboration],
  initialValue,
});
({
doc: provider.document,
initialReady: createHocuspocusInitialReadiness(provider),
awareness: provider.awareness,
rootName: roomId,
cursorData: { validate: isCollaborator },
});
=
useYjsAdmissionStatus
(editor);
return (
<Editor
readOnly={status.state !== "ready"}
aria-busy={status.state === "waiting"}
/>
);
editor.api.yjs.setCursorData({ name: "Ada", color: "#7c3aed" });
 
const disconnect = () => {
  editor.api.yjs.clearSelection();
  provider.disconnect();
};
 
const reconnect = async () => {
  await provider.connect();
  if (editor.api.yjs.admissionStatus().state === "ready") {
    editor.api.yjs.syncSelection();
  }
};