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:
- Store one authoritative document state on the server:
doc,version,steps,clientIDs, andhistoryStart. - Implement the collaboration APIs:
doc,steps, andevents; optionally implementversion,reset, andsnapshots. - On the client, load the full document and version first, then create
SVeditorwithextensionsOptions.collaboration. - 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/resetIf 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-schemaimport {
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.tsThe 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}/snapshotsExample: 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;
}| Option | Use it when | Description |
|---|---|---|
enabled | You want collaboration on | When false, the collaboration extension is not enabled. |
docId | Every collaborative document needs one | Editor instances with the same docId synchronize the same document. |
apiUrl | You need to point to the collaboration API prefix | The SDK appends /api/docs/{docId}/collab/*. Same-origin Next.js can use "/". |
timeout | Requests may hang or networks are unreliable | Per-request timeout. Default is 5000ms. |
clientID | You need to identify a user or browser tab | Prefer user ID plus a tab suffix; connections in the same document should be unique. |
pollInterval | You need to tune sync frequency | Treated as at least 500ms; idle and background tabs back off automatically. |
initialVersion | You bootstrap from the server document | Must match the same /doc response as content. |
onSynced | You need to record successful sync | Fires after remote Steps are applied or local Steps are confirmed. |
onSyncError | You need error UI or telemetry | Network errors, parse errors, and rejected Steps arrive here. |
onStatusChange | Your page shows collaboration state | Receives CollabStatus. |
onResyncRequired | The server compacted its history window | Destroy and recreate the editor from the returned full doc/version. |
CollabStatus
type CollabStatus =
| "disconnected"
| "connecting"
| "connected"
| "syncing"
| "error";| Status | Meaning |
|---|---|
disconnected | Polling has not started or has stopped. |
connecting | The provider is starting and running initial sync. |
connected | Connected; the latest sync loop completed normally. |
syncing | Local Steps are being pushed. |
error | A 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:
nullReturn 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:
| Method | Path | Return |
|---|---|---|
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/restore | RestoreData |
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 pollWhen 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 initialVersionWhy 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
apiUrlis a prefix. Same-origin Next.js apps can use"/"; an empty string disables collaboration.- Initialize with both
contentandinitialVersion, and make sure they come from the same/collab/docresponse. - Each connection in the same document should have a unique
clientID. If one user opens two tabs, add different suffixes. - Collaboration requires a live network connection. Offline edits are not queued by the SDK.
- Version conflicts, Step parse failures, and permission failures should enter the error state. Production APIs need authentication, authorization, and input validation.
- If you compact Step history, implement Resync; otherwise old clients cannot catch up to server state.