CollaborationExtension

Headless, transport-agnostic real-time collaboration for LexKit, built on Lexical's own collaboration binding and Yjs (a CRDT — offline edits merge conflict-free on reconnect).

Pluggable transport Presence API Themeable cursors Offline-safe (CRDT)

Live demo

Open this page in a second browser tab and start typing. Same-origin tabs sync peer-to-peer via the browser's BroadcastChannel, so this demo needs no server at all.

Collaborative editor

Open in two tabs and type — synced peer-to-peer, no server.

Loading collaborative editor…

Install

The extension ships with LexKit. You add yjs and a transport of your choice — they are optional peer dependencies.

# Pick a transport: y-webrtc (P2P), y-websocket (server), or y-indexeddb (offline)
pnpm add yjs y-webrtc

Quick start

The only required option is providerFactory — the single seam where you plug in a transport. LexKit never bundles one, and the extension stays inert until it's set.

import {
  createEditorSystem,
  richTextExtension,
  collaborationExtension,
  type CollaborationProvider,
} from "@lexkit/editor";
import { WebrtcProvider } from "y-webrtc";
import * as Y from "yjs";

const extensions = [
  richTextExtension,
  collaborationExtension.configure({
    id: "my-room",
    username: "Ada",
    cursorColor: "#e11d48",
    providerFactory: (id, docMap) => {
      let doc = docMap.get(id);
      if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); }
      return new WebrtcProvider(id, doc) as unknown as CollaborationProvider;
    },
  }),
] as const;

const { Provider, useEditor } = createEditorSystem<typeof extensions>();

Choose a transport

Anything implementing the Yjs Provider interface works.

y-websocket

import { WebsocketProvider } from "y-websocket";

providerFactory: (id, docMap) => {
  let doc = docMap.get(id);
  if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); }
  return new WebsocketProvider("wss://your-server", id, doc) as any;
}

y-webrtc

import { WebrtcProvider } from "y-webrtc";

providerFactory: (id, docMap) => {
  let doc = docMap.get(id);
  if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); }
  // Same-origin tabs sync via BroadcastChannel with no signaling at all.
  // Add signaling servers for cross-device collaboration.
  return new WebrtcProvider(id, doc, {
    signaling: ["wss://your-signaling-server"],
  }) as any;
}

y-indexeddb (offline)

import { IndexeddbPersistence } from "y-indexeddb";

providerFactory: (id, docMap) => {
  let doc = docMap.get(id);
  if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); }
  // Persist locally so edits survive reloads and replay when back online.
  new IndexeddbPersistence(id, doc);
  return new WebsocketProvider("wss://your-server", id, doc) as any;
}

Presence

Build any presence UI with the headless useCollaborators() hook, or drive it imperatively through commands.collab.

useCollaborators()

import { useCollaborators } from "@lexkit/editor";

function PresenceBar() {
  const { users, isConnected } = useCollaborators();
  return (
    <div>
      {isConnected ? "live" : "offline"} · {users.length} online
      {users.map((u) => (
        <span key={u.clientId} style={{ color: u.color }}>
          {u.name}{u.isLocal ? " (you)" : ""}
        </span>
      ))}
    </div>
  );
}

commands.collab

const { commands, activeStates } = useEditor();

commands.collab.connect();
commands.collab.disconnect();
commands.collab.toggle();
commands.collab.setLocalUser({ name: "Ada", color: "#e11d48", awarenessData: { avatar } });
commands.collab.getUsers();         // CollaboratorState[]
commands.collab.onUsersChange(cb);  // subscribe; returns unsubscribe
activeStates.isCollaborating;       // boolean

Themeable cursors

Remote carets are rendered by Lexical's reliable cursor binding into a container you can style via the theme, and each user's color comes from cursorColor. Supply your own cursorsContainerRef to portal carets anywhere.

theme.collab

const theme = {
  collab: {
    cursorsContainer: "my-cursors-layer",
    caret: "my-collab-caret",
    label: "my-collab-label",
    selection: "my-collab-selection",
  },
};

Offline & reliability

Yjs is a CRDT: when a client drops offline and keeps editing, then reconnects, all edits merge automatically with no data loss and every replica converges to the same state. Add y-indexeddb to persist a client's edits locally so they survive reloads and replay on reconnect. This behaviour is identical no matter how content is imported (Markdown, HTML or JSON) — collaboration syncs the editor state itself.

Notes

  • Undo/redo & history: Yjs owns history while collaborating. Lexical's collaboration binding does not wire a Yjs undo manager, so do not register historyExtension at the same time as the collaboration extension.
  • Initial content: exactly one client should seed the document (shouldBootstrap, default true). Set it to false on clients that join an existing room.
  • Multiple editors on one page: wrap each editor in its own collaboration context so they don't share a Yjs document map.
  • Custom nodes: exclude transient props (e.g. an image's uploading flag) from sync via excludedProperties.