Skip to main content
The @veltdev/lexical-crdt-react and @veltdev/lexical-crdt libraries enable real-time collaborative editing on Lexical Editors. The collaboration editing engine is built on top of Yjs, the official @lexical/yjs binding, and Velt SDK.

Prerequisites

  • Node.js (v14 or higher)
  • A Velt account with an API key (sign up)
  • React (v18 or higher) when using the React wrapper
  • Optional: TypeScript for type safety

Setup

Step 1: Install Dependencies

Install the required packages:

Step 2: Setup Velt

Initialize Velt, authenticate a user, and set a stable document context before collaboration starts. Follow the Velt Setup Docs for the production token endpoint used by authProvider.

Step 3: Initialize Collaborative Editor

  • Set the Lexical composer’s editorState to null because collaboration owns the document state.
  • Add the collaboration hook or plugin inside <LexicalComposer> so it can read the editor from LexicalComposerContext.
  • Pass an editorId to uniquely identify each editor instance you have in your app.
Use LexicalCollaborationPlugin when you only need lifecycle wiring and do not need to render reactive collaboration state:

Step 4: Use an Explicit Editor Instance in React (Optional)

If you create and hold a LexicalEditor instance outside LexicalComposer, use useCollaboration() and pass the instance explicitly:

Step 5: Status Monitoring (Optional)

Both hooks return reactive status, isSynced, and error state:
For event-style subscriptions, use the manager:

Step 6: Version Management (Optional)

Save and restore named snapshots through the manager. Restored versions are applied to every connected user in real time.
When your application already has the complete Version object, use the lower-level state application API instead of restoreVersion():

Step 7: Force Reset Initial Content (Optional)

By default, initialContent is applied exactly once when the shared document is brand new. Use a stringified serialized Lexical editor state. React wrappers may also accept the wrapper’s documented plain-text convenience form.
Use forceResetInitialContent only for deliberate reset workflows. It clears the current shared document and replaces it with initialContent for every connected user.

Step 8: Style Collaboration Cursors

Remote carets, labels, and text selections render through Lexical’s collaboration cursor layer. Pass user identity through cursorData, then style the Lexical collaboration theme classes:

Step 9: CRDT Event Subscription (Optional)

Listen for real-time CRDT data events using getCrdtElement().on() from the Velt client.

Step 10: Custom Encryption (Optional)

Encrypt CRDT data before it is stored in Velt by registering a custom encryption provider. For CRDT methods, input data is of type Uint8Array | number[].
See also: setEncryptionProvider() · VeltEncryptionProvider · EncryptConfig · DecryptConfig

Step 11: Error Handling (Optional)

Pass an onError callback to handle initialization or runtime errors. The reactive error state is also available for rendering.

Step 12: Access Yjs Internals (Advanced)

The hooks return lower-level Lexical, Yjs, and Velt handles through primitives:
Let the manager own binding setup, provider attachment, awareness, and Yjs history. Do not wire @lexical/yjs manually alongside it.

Step 13: Cleanup

The hooks destroy the manager automatically when the component unmounts or when the editorId, editor, or Velt client changes. Pass enabled: false to tear down collaboration early while keeping the component mounted.

Notes

  • Unique editorId: Use a unique editorId per editor instance.
  • Composer state: Set editorState: null because collaboration owns the shared Lexical state.
  • Composer hook: Use useLexicalComposerCollaboration() inside <LexicalComposer>.
  • Explicit editor: Use useCollaboration() only when you already hold a LexicalEditor instance.
  • Plugin form: Use LexicalCollaborationPlugin when you do not need reactive status, error, or manager values.
  • Cursor identity: Pass cursorData so remote carets and selections can display user information.
  • Lifecycle: The hooks wait for the Lexical root element, Velt initialization, and an authenticated user before creating the manager.
  • Other Frameworks lifecycle: Create and attach Lexical first, then create one manager; destroy the manager before discarding the editor.
  • Collaborative history: Do not register Lexical’s normal history plugin. Use the binding’s Yjs-backed undo manager from manager.getUndoManager().
  • Debounced writes: The Store default is debounceMs: 0, so backend writes are immediate when the option is omitted. Lexical’s separate snapshot-flush scheduler falls back to 250 ms after applying seeded or prefetched snapshot state.
  • Dependency deduplication: Keep one copy of lexical, each @lexical/* package, and yjs in the application bundle.
  • Production authentication: Do not expose privileged server tokens in browser code; use Velt’s recommended server-backed authentication flow.

Testing and Debugging

To test collaboration:
  1. Log in as two unique users in two separate browser profiles and open the same Velt document and Lexical editorId in both.
  2. Edit text, formatting, headings, or lists in one window and verify the changes and remote cursor appear in the other.
  3. Make concurrent edits and verify they merge at the character level.
  4. Reload both windows and verify the shared document persists.
Common issues:
  • Editor not loading: Confirm the Velt client is initialized, the user is authenticated, and the collaboration bridge renders inside <LexicalComposer>.
  • Initial content conflicts: Set the composer’s editorState to null.
  • Cursors not appearing: Pass cursorData and include the Lexical collaboration theme classes in your editor theme and CSS.
  • Changes not syncing: Confirm both users have the same Velt document context and editorId.
  • Manual reconnect: Use manager.getProvider()?.connect() to reconnect or manager.getProvider()?.disconnect() to test an offline transition.
Enable browser console warnings to see detailed debugging information. You can also use the VeltCrdtStoreMap debugging interface to inspect and monitor your CRDT stores in real time.

Complete Example

A complete collaborative Lexical editor with user login, status display, cursor identity, and version management.App.tsx
Complete App.tsx
Editor.tsx
Complete Editor.tsx

How It Works

  1. Velt initialization and authentication happen through VeltProvider in React or initVelt() and identify() in other frameworks. Both paths establish the same stable document context.
  2. React wrappers obtain the editor from LexicalComposerContext or accept an explicit editor. Other frameworks create the Lexical editor, register its nodes, and attach its root before creating collaboration.
  3. The collaboration manager creates a Velt CRDT store backed by a Yjs document and a shared Y.XmlText.
  4. The official @lexical/yjs binding connects Lexical updates to the shared Y.XmlText, so text, formatting, headings, and concurrent edits merge at the character level.
  5. Yjs awareness drives the manager-owned remote cursor overlay, while the binding’s Y.UndoManager provides collaboration-aware undo and redo.
  6. Version management saves the complete Yjs state as a named snapshot that can be restored for every connected user.
  7. React cleanup is automatic when the wrapper unmounts or collaboration is disabled. Other frameworks unsubscribe callbacks and destroy the manager before discarding Lexical.

APIs

React: useLexicalComposerCollaboration()

The primary hook for applications using <LexicalComposer>. It reads the editor from context, waits for the root element, and manages the collaboration lifecycle.
  • Signature: useLexicalComposerCollaboration(config: UseLexicalComposerCollaborationConfig): UseCollaborationReturn
  • Alias: useLexicalCollaboration
  • Params:
    • editorId: Unique identifier for the collaborative editor.
    • veltClient: Optional explicit Velt client. Falls back to VeltProvider context.
    • enabled: Delay initialization or destroy active collaboration. Default: true.
    • initialContent: Serialized Lexical editor state or plain text for a brand-new document.
    • forceResetInitialContent: Replace existing shared content during initialization. Default: false.
    • cursorData: Cursor name, color, and custom awareness data.
    • debounceMs: Throttle interval in milliseconds for backend writes. Store default: 0 ms (immediate). The internal snapshot-flush scheduler separately falls back to 250 ms when this option is omitted.
    • onError: Callback for non-fatal collaboration errors.
  • Returns:
    • primitives: Lexical, Yjs, and Velt integration handles. null while loading.
    • isLoading: true until the manager is initialized.
    • isSynced: true after initial backend sync completes.
    • status: 'connecting', 'connected', or 'disconnected'.
    • error: Most recent error, or null.
    • manager: Underlying CollaborationManager, or null before initialization.

React: useCollaboration()

Explicit-editor form of the React hook. It has the same lifecycle and return values, but requires an editor: LexicalEditor config value.

React: LexicalCollaborationPlugin

Null-rendering component form of useLexicalComposerCollaboration(). Use it when you only need collaboration lifecycle wiring.

Other Frameworks: createCollaboration()

The framework-agnostic createCollaboration() factory from @veltdev/lexical-crdt creates and initializes the manager for an existing LexicalEditor.
  • Signature: createCollaboration(config: CollaborationConfig): Promise<CollaborationManager>
  • Required config: editorId, editor, and an initialized, authenticated veltClient.
  • Optional config: initialContent, cursorData, debounceMs (Store default: 0 ms), forceResetInitialContent, and onError. The internal snapshot-flush scheduler separately falls back to 250 ms when debounceMs is omitted.
  • Cleanup: Call manager.destroy() before disposing the editor.

LexicalCollaborationPrimitives

LexicalProviderAdapter

LexicalProviderAdapter is exported from @veltdev/lexical-crdt. It adapts Velt’s SyncProvider events to the provider interface expected by @lexical/yjs; the collaboration manager creates it automatically, so most applications should access the existing adapter through manager.getLexicalProvider() instead of constructing another one.

Version

CollaborationManager Methods

manager.onStatusChange()

Subscribe to connection status changes.
  • Signature: manager.onStatusChange(callback: (status: SyncStatus) => void): Unsubscribe

manager.onSynced()

Subscribe to initial sync state changes.
  • Signature: manager.onSynced(callback: (synced: boolean) => void): Unsubscribe

manager.initialized

Whether initialization has completed.
  • Returns: boolean

manager.synced

Whether initial backend sync has completed.
  • Returns: boolean

manager.status

Current connection status.
  • Returns: SyncStatus ('connecting' | 'connected' | 'disconnected')

manager.saveVersion()

Save a named snapshot and return its version ID.
  • Signature: manager.saveVersion(name: string): Promise<string>

manager.getVersions()

List all saved versions.
  • Returns: Promise<Version[]>

manager.restoreVersion()

Restore a saved version for all connected users.
  • Signature: manager.restoreVersion(versionId: string): Promise<boolean>

manager.setStateFromVersion()

Apply a Version object’s state to the shared document.
  • Signature: manager.setStateFromVersion(version: Version): Promise<void>

manager.getEditor()

Get the bound Lexical editor.
  • Returns: LexicalEditor | null

manager.getBinding()

Get the @lexical/yjs binding.
  • Returns: Binding | null

manager.getLexicalProvider()

Get the provider adapter used by the Lexical binding.
  • Returns: Provider | null

manager.getDoc()

Get the underlying Yjs document.
  • Returns: Y.Doc | null

manager.getXmlText()

Get the shared Y.XmlText bound to Lexical content.
  • Returns: Y.XmlText | null

manager.getXmlFragment()

Alias of getXmlText() provided for parity with other CRDT integrations.
  • Returns: Y.XmlText | null

manager.getProvider()

Get the Velt sync provider.
  • Returns: SyncProvider | null

manager.getAwareness()

Get the Yjs awareness instance.
  • Returns: Awareness | null

manager.getUndoManager()

Get the collaborative Yjs undo manager.
  • Returns: Y.UndoManager | null

manager.getStore()

Get the underlying Velt CRDT store.
  • Returns: Store<string> | null

manager.destroy()

Destroy the binding, provider adapter, awareness state, listeners, and store. The React hooks call this automatically.
  • Returns: void

Custom Encryption

Encrypt CRDT data before it is stored in Velt by registering a custom encryption provider.

Limitations

  • Content written through the CRDT REST API is not currently materialized into the Lexical editor. Browser-to-REST reads work, but REST-to-browser content does not.
  • Online and offline concurrent editor changes merge at the character level through the official @lexical/yjs binding.