SVeditor Docs

Real-time Collaboration

Collaboration guide covering Next.js integration, collaboration APIs, Resync, snapshots, options, and ProseMirror Step internals.

Real-time Collaboration

SVeditor collaboration lets multiple editor instances edit the same document. It is built on ProseMirror Step synchronization over HTTP polling, so you do not need WebSocket infrastructure.

The usual integration path is:

  1. Store one authoritative document state on the server: doc, version, steps, clientIDs, and historyStart.
  2. Implement the collaboration APIs: doc, steps, and events; optionally implement version, reset, and snapshots.
  3. On the client, load the full document and version first, then create SVeditor with extensionsOptions.collaboration.
  4. If the server returns Resync, destroy and recreate the editor from the server's full document.

The examples below assume you already have a Next.js App Router project and can import SVeditor. They do not cover creating the Next.js project itself.

Integrate In A Next.js App

1. Understand the URLs the SDK calls

extensionsOptions.collaboration.apiUrl is the collaboration API prefix, not the full /collab endpoint. The SDK builds these URLs:

GET  {apiUrl}/api/docs/{docId}/collab/version
GET  {apiUrl}/api/docs/{docId}/collab/doc
POST {apiUrl}/api/docs/{docId}/collab/steps
GET  {apiUrl}/api/docs/{docId}/collab/events?since={version}
POST {apiUrl}/api/docs/{docId}/collab/reset

If your Next.js API lives on the same site, for example /api/docs/:docId/collab/doc, pass:

collaboration: {
  enabled: true,
  docId,
  apiUrl: "/",
}

Do not pass apiUrl: "". An empty string disables the collaboration extension. If your backend is hosted on another origin or sub-path, pass a prefix such as https://api.example.com or /sdk-demo.

2. Install the shared server schema

The collaboration backend must parse doc and steps with the same ProseMirror schema used by the editor. Install the shared schema package on the server or any other collaboration participant:

pnpm add @wztlink1013/sveditor-collab-schema
import {
  getSveditorCollabSchema,
  ProseMirrorNode,
  Step,
} from "@wztlink1013/sveditor-collab-schema";

const schema = getSveditorCollabSchema();

const doc = ProseMirrorNode.fromJSON(schema, incomingDocJson);
const step = Step.fromJSON(schema, incomingStepJson);

Keep every collaboration participant on the same package version. Otherwise Steps created by one client may fail to deserialize or apply on the server.

3. Prepare server collaboration state

In production, store this state in a database, Redis, or a dedicated collaboration service. To prove the flow first, you can use an in-memory Map. Put this in lib/collab-memory.ts:

import {
  getSveditorCollabSchema,
  ProseMirrorNode,
  Step,
} from "@wztlink1013/sveditor-collab-schema";

const MAX_STEPS_WINDOW = 100;

const emptyDoc = {
  type: "doc",
  content: [{ type: "paragraph" }],
} satisfies Record<string, unknown>;

interface CollabState {
  doc: Record<string, unknown>;
  baseDoc: Record<string, unknown>;
  version: number;
  historyStart: number;
  steps: Record<string, unknown>[];
  clientIDs: Array<string | number>;
}

const documents = new Map<string, CollabState>();

function cloneDoc(doc: Record<string, unknown>) {
  return structuredClone(doc);
}

function getOrCreateState(docId: string): CollabState {
  const existing = documents.get(docId);

  if (existing) {
    return existing;
  }

  const doc = cloneDoc(emptyDoc);
  const state: CollabState = {
    doc,
    baseDoc: cloneDoc(doc),
    version: 0,
    historyStart: 0,
    steps: [],
    clientIDs: [],
  };

  documents.set(docId, state);
  return state;
}

function compactHistory(state: CollabState) {
  if (state.steps.length <= MAX_STEPS_WINDOW) {
    return;
  }

  const schema = getSveditorCollabSchema();
  const overflow = state.steps.length - MAX_STEPS_WINDOW;
  let baseDoc = ProseMirrorNode.fromJSON(schema, state.baseDoc);

  for (let index = 0; index < overflow; index += 1) {
    const step = Step.fromJSON(schema, state.steps[index]);
    const result = step.apply(baseDoc);

    if (result.failed || !result.doc) {
      throw new Error(result.failed || "Failed to compact collaboration steps");
    }

    baseDoc = result.doc;
  }

  state.baseDoc = baseDoc.toJSON() as Record<string, unknown>;
  state.steps = state.steps.slice(overflow);
  state.clientIDs = state.clientIDs.slice(overflow);
  state.historyStart += overflow;
}

export function getCollabDoc(docId: string) {
  const state = getOrCreateState(docId);

  return {
    doc: state.doc,
    version: state.version,
  };
}

export function applyCollabSteps(input: {
  docId: string;
  version: number;
  steps: Record<string, unknown>[];
  clientID?: string | number;
}) {
  const state = getOrCreateState(input.docId);

  if (input.version !== state.version) {
    return {
      accepted: false,
      version: state.version,
      error: `Version mismatch: client ${input.version}, server ${state.version}`,
    };
  }

  if (input.steps.length === 0) {
    return {
      accepted: true,
      version: state.version,
    };
  }

  const schema = getSveditorCollabSchema();
  let pmDoc = ProseMirrorNode.fromJSON(schema, state.doc);
  const appliedSteps: Record<string, unknown>[] = [];
  const appliedClientIDs: Array<string | number> = [];

  for (const stepJson of input.steps) {
    const step = Step.fromJSON(schema, stepJson);
    const result = step.apply(pmDoc);

    if (result.failed || !result.doc) {
      return {
        accepted: false,
        version: state.version,
        error: result.failed || "Failed to apply step",
      };
    }

    pmDoc = result.doc;
    appliedSteps.push(stepJson);
    appliedClientIDs.push(input.clientID ?? "anonymous");
  }

  state.doc = pmDoc.toJSON() as Record<string, unknown>;
  state.steps.push(...appliedSteps);
  state.clientIDs.push(...appliedClientIDs);
  state.version += appliedSteps.length;
  compactHistory(state);

  return {
    accepted: true,
    version: state.version,
  };
}

export function getCollabEvents(input: { docId: string; since: number }) {
  const state = getOrCreateState(input.docId);

  if (input.since < 0 || input.since >= state.version) {
    return null;
  }

  if (input.since < state.historyStart) {
    return {
      resync: true,
      doc: state.doc,
      version: state.version,
    };
  }

  const offset = input.since - state.historyStart;

  return {
    steps: state.steps.slice(offset),
    clientIDs: state.clientIDs.slice(offset),
    version: state.version,
  };
}

export function resetCollabDoc(docId: string, doc = cloneDoc(emptyDoc)) {
  documents.set(docId, {
    doc,
    baseDoc: cloneDoc(doc),
    version: 0,
    historyStart: 0,
    steps: [],
    clientIDs: [],
  });

  return { success: true, version: 0 };
}

The in-memory version is for local validation only. Production systems should authenticate the user, authorize document access, and persist state in reliable storage.

4. Implement the collaboration API

In Next.js App Router, use this route layout:

app/api/docs/[docId]/collab/version/route.ts
app/api/docs/[docId]/collab/doc/route.ts
app/api/docs/[docId]/collab/steps/route.ts
app/api/docs/[docId]/collab/events/route.ts
app/api/docs/[docId]/collab/reset/route.ts

The doc endpoint returns the full document and current version. The client should also use this endpoint to bootstrap before entering collaboration:

import { NextResponse } from "next/server";
import { getCollabDoc } from "@/lib/collab-memory";

export async function GET(
  _request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  return NextResponse.json(getCollabDoc(docId));
}

The steps endpoint receives local Steps from a client. Return JSON for version conflicts too, so the client can read the server version and error reason:

import { NextResponse } from "next/server";
import { applyCollabSteps } from "@/lib/collab-memory";

export async function POST(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const body = (await request.json()) as {
    version: number;
    steps: Record<string, unknown>[];
    clientID?: string | number;
  };

  return NextResponse.json(
    applyCollabSteps({
      docId,
      version: body.version,
      steps: body.steps,
      clientID: body.clientID,
    }),
  );
}

The events endpoint returns incremental Steps or a Resync payload:

import { NextResponse } from "next/server";
import { getCollabEvents } from "@/lib/collab-memory";

export async function GET(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const url = new URL(request.url);
  const since = Number(url.searchParams.get("since") ?? "0");

  return NextResponse.json(getCollabEvents({ docId, since }));
}

version and reset are helper endpoints:

import { NextResponse } from "next/server";
import { getCollabDoc, resetCollabDoc } from "@/lib/collab-memory";

export async function GET(
  _request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  return NextResponse.json({ version: getCollabDoc(docId).version });
}

export async function POST(
  request: Request,
  context: { params: Promise<{ docId: string }> },
) {
  const { docId } = await context.params;
  const body = (await request.json().catch(() => ({}))) as {
    doc?: Record<string, unknown>;
  };

  return NextResponse.json(resetCollabDoc(docId, body.doc));
}

When creating actual files, put GET /version and POST /reset in their own route.ts files.

5. Create the client collaboration editor

In collaboration mode, load the full document and version before creating the editor. Do not mount an empty editor with only collaboration configured, because local initial content and server version may diverge.

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { SVeditor } from "@wztlink1013/sveditor";
import type {
  CollabStatus,
  EditorInstance,
  EditorOptions,
} from "@wztlink1013/sveditor";
import "@wztlink1013/sveditor/style.css";

type EditorInitOptions = Omit<EditorOptions, "el">;

interface CollabBootstrap {
  doc: Record<string, unknown>;
  version: number;
}

interface CollabEditorProps {
  docId: string;
  userId: string;
}

async function loadCollabDoc(docId: string): Promise<CollabBootstrap> {
  const response = await fetch(`/api/docs/${docId}/collab/doc`, {
    cache: "no-store",
  });

  if (!response.ok) {
    throw new Error(`Failed to load collaborative document: ${response.status}`);
  }

  return (await response.json()) as CollabBootstrap;
}

export function CollabEditor({ docId, userId }: CollabEditorProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const editorRef = useRef<EditorInstance | null>(null);
  const [bootstrap, setBootstrap] = useState<CollabBootstrap | null>(null);
  const [status, setStatus] = useState<CollabStatus>("disconnected");
  const [error, setError] = useState<string | null>(null);
  const [resyncContent, setResyncContent] = useState<string | null>(null);
  const [resyncVersion, setResyncVersion] = useState<number | null>(null);
  const [editorKey, setEditorKey] = useState(0);

  useEffect(() => {
    let cancelled = false;

    setBootstrap(null);
    setError(null);
    setResyncContent(null);
    setResyncVersion(null);
    setEditorKey((current) => current + 1);

    void loadCollabDoc(docId)
      .then((data) => {
        if (!cancelled) {
          setBootstrap(data);
        }
      })
      .catch((reason) => {
        if (!cancelled) {
          setError(reason instanceof Error ? reason.message : "Load failed");
        }
      });

    return () => {
      cancelled = true;
    };
  }, [docId]);

  const options = useMemo<EditorInitOptions | null>(() => {
    if (!bootstrap) {
      return null;
    }

    return {
      content: resyncContent ?? JSON.stringify(bootstrap.doc),
      extensionsOptions: {
        collaboration: {
          enabled: true,
          docId,
          apiUrl: "/",
          clientID: userId,
          initialVersion: resyncVersion ?? bootstrap.version,
          pollInterval: 800,
          timeout: 5000,
          onStatusChange: setStatus,
          onSyncError: (syncError) => {
            setError(syncError.message);
          },
          onResyncRequired: (doc, version) => {
            editorRef.current?.destroy();
            setResyncContent(JSON.stringify(doc.toJSON()));
            setResyncVersion(version);
            setEditorKey((current) => current + 1);
          },
        },
      },
      onCreate: (instance) => {
        editorRef.current = instance;
      },
      onDestroy: () => {
        editorRef.current = null;
      },
    };
  }, [bootstrap, docId, resyncContent, resyncVersion, userId]);

  useEffect(() => {
    if (!containerRef.current || !options) {
      return;
    }

    const editor = SVeditor.create({
      el: containerRef.current,
      ...options,
    });

    return () => editor.destroy();
  }, [editorKey, options]);

  if (error) {
    return <p>Collaboration failed to load or sync: {error}</p>;
  }

  if (!bootstrap) {
    return <p>Loading collaborative document...</p>;
  }

  return (
    <section className="collab-editor">
      <div className="collab-editor__status">Collaboration status: {status}</div>
      <div key={editorKey} ref={containerRef} style={{ minHeight: 640 }} />
    </section>
  );
}

6. Use it from a page

The page only needs a document ID and current user ID. In production, get the user ID from your auth session:

import { CollabEditor } from "@/components/collab-editor";

export default async function Page({
  params,
}: {
  params: Promise<{ docId: string }>;
}) {
  const { docId } = await params;
  const userId = "user-123";

  return <CollabEditor docId={docId} userId={userId} />;
}

Open the same docId in two browser windows to see Steps synchronize through polling.

Common Integration Modes

Two collaboration editors on one page

For local validation, mount two editor instances on the same page. The key rules: each connection needs a different clientID, while docId, content, and initialVersion should come from the same bootstrap response.

const editorA = SVeditor.create({
  el: "#editor-a",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: `${userId}-a`,
      initialVersion: bootstrap.version,
    },
  },
});

const editorB = SVeditor.create({
  el: "#editor-b",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: `${userId}-b`,
      initialVersion: bootstrap.version,
    },
  },
});

Collaborative body with separate metadata saves

Collaboration owns body synchronization. Do not also enable autoSave.onSaveContent for the same body. Title, emoji, cover, and other metadata can still use extensionsOptions.meta:

SVeditor.create({
  el: "#editor",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: userId,
      initialVersion: bootstrap.version,
    },
    meta: {
      onFetchMeta: () => fetchDocMeta(docId),
      onSaveMeta: (meta) => saveDocMeta(docId, meta),
      onMetaChange: (meta) => setTitlePreview(meta.name ?? ""),
    },
  },
});

Add version history

versionHistory can be enabled with collaboration. The current SDK derives snapshot endpoints from collaboration.apiUrl and collaboration.docId, so you do not pass collabConfig inside versionHistory:

SVeditor.create({
  el: "#editor",
  content: JSON.stringify(bootstrap.doc),
  extensionsOptions: {
    collaboration: {
      enabled: true,
      docId,
      apiUrl: "/",
      clientID: userId,
      initialVersion: bootstrap.version,
    },
    versionHistory: {
      enabled: true,
      autoSnapshot: {
        enabled: true,
        debounceMs: 15000,
      },
    },
  },
});

This calls:

GET    {apiUrl}/api/docs/{docId}/snapshots
POST   {apiUrl}/api/docs/{docId}/snapshots
DELETE {apiUrl}/api/docs/{docId}/snapshots/{snapshotId}
GET    {apiUrl}/api/docs/{docId}/snapshots/{snapshotId}/restore
DELETE {apiUrl}/api/docs/{docId}/snapshots

Example: multiple editors on one document

To test collaboration locally, mount two editor instances with the same docId and point apiUrl at your backend prefix:

const sharedExtensions = {
  collaboration: {
    enabled: true,
    docId,
    apiUrl: "https://api.example.com", // or "/api" when proxied
    pollInterval: 2000,
  },
};

Requests use {apiUrl}/api/docs/{docId}/collab/* as documented above.

SVeditor.create({
  el: "#editor-a",
  content: bootstrap.content,
  extensionsOptions: {
    ...sharedExtensions,
    collaboration: {
      ...sharedExtensions.collaboration,
      clientID: `${userId}-a`,
      initialVersion: bootstrap.version,
    },
  },
});

Configuration

extensionsOptions.collaboration

interface CollaborationExtensionOptions {
  enabled: boolean;
  docId: string;
  apiUrl: string;
  timeout?: number;
  clientID?: string | number;
  pollInterval?: number;
  initialVersion?: number;
  onSynced?: () => void;
  onSyncError?: (error: Error) => void;
  onStatusChange?: (status: CollabStatus) => void;
  onResyncRequired?: (doc: ProseMirrorNode, version: number) => void;
}
OptionUse it whenDescription
enabledYou want collaboration onWhen false, the collaboration extension is not enabled.
docIdEvery collaborative document needs oneEditor instances with the same docId synchronize the same document.
apiUrlYou need to point to the collaboration API prefixThe SDK appends /api/docs/{docId}/collab/*. Same-origin Next.js can use "/".
timeoutRequests may hang or networks are unreliablePer-request timeout. Default is 5000ms.
clientIDYou need to identify a user or browser tabPrefer user ID plus a tab suffix; connections in the same document should be unique.
pollIntervalYou need to tune sync frequencyTreated as at least 500ms; idle and background tabs back off automatically.
initialVersionYou bootstrap from the server documentMust match the same /doc response as content.
onSyncedYou need to record successful syncFires after remote Steps are applied or local Steps are confirmed.
onSyncErrorYou need error UI or telemetryNetwork errors, parse errors, and rejected Steps arrive here.
onStatusChangeYour page shows collaboration stateReceives CollabStatus.
onResyncRequiredThe server compacted its history windowDestroy and recreate the editor from the returned full doc/version.

CollabStatus

type CollabStatus =
  | "disconnected"
  | "connecting"
  | "connected"
  | "syncing"
  | "error";
StatusMeaning
disconnectedPolling has not started or has stopped.
connectingThe provider is starting and running initial sync.
connectedConnected; the latest sync loop completed normally.
syncingLocal Steps are being pushed.
errorA sync request, Step application, or server response failed.

Backend API Requirements

GET /api/docs/:docId/collab/version

Return the current version:

{
  "version": 42
}

GET /api/docs/:docId/collab/doc

Return the full ProseMirror document and current version:

{
  "doc": { "type": "doc", "content": [] },
  "version": 42
}

The client should use this response as the source for content and initialVersion before creating the editor.

POST /api/docs/:docId/collab/steps

Submit local Steps from a client:

{
  "version": 42,
  "steps": [{ "stepType": "replace" }],
  "clientID": "user-123"
}

Accepted response:

{
  "accepted": true,
  "version": 43
}

Rejected response:

{
  "accepted": false,
  "version": 44,
  "error": "Version mismatch: client 42, server 44"
}

GET /api/docs/:docId/collab/events?since=42

Return null when there are no new events:

null

Return incremental Steps when available:

{
  "steps": [{ "stepType": "replace" }],
  "clientIDs": ["user-456"],
  "version": 43
}

Return Resync when the client version is older than the server's retained Step window:

{
  "resync": true,
  "doc": { "type": "doc", "content": [] },
  "version": 100
}

Snapshot APIs

When versionHistory is enabled, implement the snapshot APIs too:

MethodPathReturn
GET/api/docs/:docId/snapshots{ snapshots: CollabSnapshot[] }
POST/api/docs/:docId/snapshots{ snapshot: CollabSnapshot }
DELETE/api/docs/:docId/snapshots/:snapshotId{ success: true } or 404
GET/api/docs/:docId/snapshots/:snapshotId/restoreRestoreData
DELETE/api/docs/:docId/snapshots{ success: true }

How It Works

Steps and versions

A ProseMirror Step is one atomic document change, such as inserting text, deleting a node, or changing formatting. Collaboration does not repeatedly transmit the full document; it exchanges Steps between clients.

The server maintains a monotonically increasing version. Each accepted Step increments version by 1. A client must submit Steps with the version they are based on, and the server should only accept submissions that match the current version.

Sync loop

Each sync loop roughly does this:

Client reads local collaboration version
  |
  |-- GET /events?since=version
  |     |-- returns steps: apply remote Steps
  |     |-- returns null: no remote changes
  |     |-- returns resync: stop polling, call onResyncRequired
  |
  |-- Check whether local Steps are sendable
        |-- yes: POST /steps
        |-- no: wait for next poll

When local input creates sendable Steps, the SDK wakes the sync loop immediately instead of waiting for the next fixed poll. If nothing changes for a while, polling backs off automatically; background browser tabs also poll less frequently.

Step window and Resync

Servers usually should not retain every Step forever. A common approach is to keep only the latest N Steps and fold older Steps into baseDoc. When a client has been away long enough that its since version is earlier than historyStart, the server can no longer provide a complete incremental history and must return Resync:

client version < historyStart
  |
  |-- server returns { resync: true, doc, version }
  |
  |-- client destroys the editor
  |-- rebuilds with doc as content
  |-- rebuilds with version as initialVersion

Why this is not auto-save

Auto-save fits single-user or non-collaborative body persistence: after the user edits, the latest body is saved to the server. Collaboration fits simultaneous editing: the server arbitrates Step order and broadcasts increments to other clients.

Do not persist the same body with both autoSave.onSaveContent and collaboration. Let the collaboration service own the body. Use extensionsOptions.meta separately for title, emoji, cover, and other metadata.

Important Notes

  1. apiUrl is a prefix. Same-origin Next.js apps can use "/"; an empty string disables collaboration.
  2. Initialize with both content and initialVersion, and make sure they come from the same /collab/doc response.
  3. Each connection in the same document should have a unique clientID. If one user opens two tabs, add different suffixes.
  4. Collaboration requires a live network connection. Offline edits are not queued by the SDK.
  5. Version conflicts, Step parse failures, and permission failures should enter the error state. Production APIs need authentication, authorization, and input validation.
  6. If you compact Step history, implement Resync; otherwise old clients cannot catch up to server state.