Skip to main content

Documentation Index

Fetch the complete documentation index at: https://superdoc-nick-sd-3220-overlapping-suggestion-contract.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Hocuspocus is TipTap’s open-source Yjs WebSocket server. It’s a mature, battle-tested option for self-hosted collaboration.

Setup

Server

npm install @hocuspocus/server yjs
import { Server } from "@hocuspocus/server";
import * as Y from "yjs";

const server = Server.configure({
  port: 1234,

  async onLoadDocument(data) {
    // Load the stored Yjs update from your database.
    const state = await db.getDocument(data.documentName);
    if (state) {
      Y.applyUpdate(data.document, state);
    }
    return data.document;
  },

  async onStoreDocument(data) {
    // Store the Yjs document as a binary update.
    const state = Y.encodeStateAsUpdate(data.document);
    await db.saveDocument(data.documentName, Buffer.from(state));
  },

  async onAuthenticate(data) {
    // Validate token
    const user = await validateToken(data.token);
    if (!user) {
      throw new Error("Unauthorized");
    }
    return { user };
  },
});

server.listen();

Client

Use the provider-agnostic API to connect SuperDoc:
npm install @hocuspocus/provider yjs
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
import { SuperDoc } from "superdoc";

const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
  url: "ws://localhost:1234",
  name: "document-123",
  document: ydoc,
  token: "auth-token", // Optional
});

// Wait for sync before creating editor
provider.on("synced", () => {
  const superdoc = new SuperDoc({
    selector: "#editor",
    documentMode: "editing",
    user: {
      name: "John Smith",
      email: "john@example.com",
    },
    modules: {
      collaboration: { ydoc, provider },
    },
  });
});
SuperDoc JS always uses the same collaboration contract, regardless of provider: modules.collaboration = { ydoc, provider }.

Seed from DOCX only for empty rooms

A DOCX file can seed a new collaboration room. After that, the Yjs document is the source of truth. Wait for Hocuspocus to sync before you decide whether the room is empty. If you check before sync, the client can look empty while the server still has stored Yjs state.
function hasSuperDocContent(ydoc: Y.Doc) {
  return (
    ydoc.getXmlFragment("supereditor").length > 0 ||
    ydoc.getMap("parts").size > 0 ||
    ydoc.getMap("meta").has("docx")
  );
}

provider.on("synced", () => {
  const shouldSeedFromDocx = !hasSuperDocContent(ydoc);

  new SuperDoc({
    selector: "#editor",
    documentMode: "editing",
    ...(shouldSeedFromDocx
      ? {
          document: {
            type: "docx",
            url: "/initial.docx",
            isNewFile: true,
          },
        }
      : {}),
    user: {
      name: "John Smith",
      email: "john@example.com",
    },
    modules: {
      collaboration: { ydoc, provider },
    },
  });
});
If your backend already knows whether a room has stored Yjs state, use that metadata. The important part is the order: sync first, inspect the Yjs document or room metadata, then decide whether to pass the DOCX seed.

React example

import { useEffect, useRef, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
import { SuperDoc } from "superdoc";
import "superdoc/style.css";

function hasSuperDocContent(ydoc: Y.Doc) {
  return (
    ydoc.getXmlFragment("supereditor").length > 0 ||
    ydoc.getMap("parts").size > 0 ||
    ydoc.getMap("meta").has("docx")
  );
}

export default function Editor() {
  const superdocRef = useRef<SuperDoc | null>(null);
  const [users, setUsers] = useState<any[]>([]);

  useEffect(() => {
    const ydoc = new Y.Doc();
    const provider = new HocuspocusProvider({
      url: "ws://localhost:1234",
      name: "my-document",
      document: ydoc,
    });

    provider.on("synced", () => {
      if (superdocRef.current) return;

      const shouldSeedFromDocx = !hasSuperDocContent(ydoc);
      superdocRef.current = new SuperDoc({
        selector: "#superdoc",
        documentMode: "editing",
        ...(shouldSeedFromDocx
          ? {
              document: {
                type: "docx",
                url: "/initial.docx",
                isNewFile: true,
              },
            }
          : {}),
        user: {
          name: `User ${Math.floor(Math.random() * 1000)}`,
          email: "user@example.com",
        },
        modules: {
          collaboration: { ydoc, provider },
        },
        onAwarenessUpdate: ({ states }) => {
          setUsers(states.filter((s) => s.user));
        },
      });
    });

    return () => {
      superdocRef.current?.destroy();
      provider.destroy();
    };
  }, []);

  return (
    <div>
      <div className="users">
        {users.map((u, i) => (
          <span key={i} style={{ background: u.user?.color }}>
            {u.user?.name}
          </span>
        ))}
      </div>
      <div id="superdoc" style={{ height: "100vh" }} />
    </div>
  );
}

Server configuration

Basic options

Server.configure({
  port: 1234,
  timeout: 30000, // Connection timeout
  debounce: 2000, // Debounce document saves
  maxDebounce: 10000, // Max wait before save
  quiet: false, // Disable logging
});

Hooks

HookPurpose
onLoadDocumentLoad document from storage
onStoreDocumentSave document to storage
onAuthenticateValidate user tokens
onChangeReact to document changes
onConnectHandle new connections
onDisconnectHandle disconnections

Persistence example

import { Server } from "@hocuspocus/server";
import { Database } from "@hocuspocus/extension-database";

const server = Server.configure({
  extensions: [
    new Database({
      fetch: async ({ documentName }) => {
        const doc = await db.findOne({ name: documentName });
        return doc?.data || null;
      },
      store: async ({ documentName, state }) => {
        await db.upsert({ name: documentName }, { data: state });
      },
    }),
  ],
});

Provider options

const provider = new HocuspocusProvider({
  url: "ws://localhost:1234",
  name: "document-id",
  document: ydoc,

  // Optional
  token: "auth-token",
  awareness: awareness, // Custom awareness instance
  connect: true, // Auto-connect on create
  preserveConnection: true, // Keep connection on destroy
  broadcast: true, // Broadcast changes to tabs
});

Events

Provider events

// Sync status
provider.on("synced", () => {
  console.log("Document synced");
});

// Connection status
provider.on("status", ({ status }) => {
  // 'connecting' | 'connected' | 'disconnected'
});

// Authentication
provider.on("authenticationFailed", ({ reason }) => {
  console.error("Auth failed:", reason);
});

Production deployment

Docker

FROM node:18-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production

COPY . .

EXPOSE 1234
CMD ["node", "server.js"]

With Redis (scaling)

import { Server } from "@hocuspocus/server";
import { Redis } from "@hocuspocus/extension-redis";

Server.configure({
  extensions: [
    new Redis({
      host: "localhost",
      port: 6379,
    }),
  ],
});

Resources

Hocuspocus Docs

Official documentation

Working example

Complete source code

Next steps

Client Configuration

All SuperDoc collaboration options

Self-hosted overview

Compare Hocuspocus, YHub, and the SuperDoc Yjs reference server