Skip to Content

Pitter Patter

Server-authoritative collaboration on the Pitter Patter  model (prosemirror-collab-commit), whose code Stesura carries in its own packages: neither is an npm dependency. A central authority orders and validates every commit, and the document is stored as ProseMirror JSON in your Postgres. Comments, presence and the shared proofread cache use the same backend.

PackageRunsContents
@stesura/collab-adapterclientcomposeCollabPlugins, presence plugin + cursor styles, long-poll listeners
@stesura/collab-adapter-reactclientuseCollabEditor, CollabProvider, RoomProvider, comment adapter, proofread cache, presence/connection hooks
@stesura/collab-adapter-serverservercreateCollabBackend, postgresStore, redisBroadcast, route factories (/next, /node), schema.sql

How it works

┌────────────── client ──────────────┐ ┌───────────── your server ─────────────┐ │ collab plugin (versions, rebase) │ HTTP │ createCollabRoutes (Next.js catch-all) │ │ presence plugin (remote cursors) │─────▶│ └─ createCollabBackend │ │ comment adapter (threads mirror) │ long │ ├─ CollabAuthority (validates, │ │ proofread cache (results mirror) │ poll │ │ orders, persists commits) │ └────────────────────────────────────┘ │ ├─ postgresStore → Postgres │ │ └─ redisBroadcast → Redis │ └────────────────────────────────────────┘
  • Postgres holds documents, commits and comment threads. Concurrent commits to one document serialize on a row lock; a losing writer violates UNIQUE(doc_id, version), rolls back, and is rebased and retried.
  • Redis holds the ephemeral parts: long-poll wake-ups, presence (with a TTL) and the shared proofread cache.
  • Clients sync over HTTP long-polling: no websockets, so it runs on serverless hosts.

Client mount

From the Pitter Patter demo :

"use client"; import "@stesura/collab-adapter/styles.css"; // remote cursor styles import { StesuraEditor, stesuraNodeViews, StesuraUserProvider } from "@stesura/editor-react"; import { CollabProvider, createCollabProofreadCacheFactory, useCollabEditor, } from "@stesura/collab-adapter-react"; import { proofreadPlugin } from "@stesura/proofread"; const DOC_ID = "my-document"; const Editor = () => { const { editorState, dispatch, pluginFactory, schema, docStatus } = useCollabEditor({ extraPlugins: ({ parentEditorId }) => [ proofreadPlugin({ debounceTimeMS: 1000, generateProofreadErrors, createCache: createCollabProofreadCacheFactory({ docId: DOC_ID }), parentEditorId, }), // ...trackChanges, pagination, references, comments plugins ], }); if (docStatus === "gone") return <DocumentRemovedState />; return ( <StesuraEditor state={editorState} dispatchTransaction={dispatch} schema={schema} pluginFactory={pluginFactory} nodeViews={stesuraNodeViews} uiExtensions={uiExtensions} /> ); }; export default function Page() { return ( <StesuraUserProvider currentUser={user} resolveUsers={resolveUsers}> <CollabProvider docId={DOC_ID}> <Editor /> </CollabProvider> </StesuraUserProvider> ); }

useCollabEditor

Takes the same arguments as the other mount hooks (editorId, schema, configuration, pluginOptions, extraPlugins, transactionModifier, initialContent), plus:

  • getPresenceDisplayName(userId): the name shown on a remote cursor.
  • getPresenceAvatar(userId): the avatar URL shown on a remote cursor. Without one, the cursor shows the name’s initials.
  • presenceCursorDisplay: "avatar" (the default; the name shows on hover), "avatar-name" or "name".
  • onInlineImageRejected(): called when a pasted base64 image is refused. Inline images are never synced; images must be uploaded (see File uploads).
  • onError: telemetry sink, see Telemetry.

editorId, configuration, pluginOptions, extraPlugins, initialContent and presenceCursorDisplay are read when the connection starts; remount (a React key) to change them. The callbacks and transactionModifier are read live.

It returns { editorState, dispatch, pluginFactory, schema, docStatus, reconnect }. docStatus is one of:

StatusMeaning
"loading"Fetching the document
"ready"Syncing
"disconnected"Sync stopped after repeated failures. Resumes on reconnect() or when a send gets through
"gone"The document was removed (410)
"error"A failure no retry fixes: a malformed snapshot, or a client/server protocol mismatch (426)
"schema-mismatch"The server refused this client’s schema, or the document or a commit names a node or mark it lacks: the server or another client runs a different schema. Prompt a reload; onError gets a SchemaMismatchError with the unknown types or both fingerprints. See schema compatibility

Loops pause while the tab has been hidden for 30 s, and resume when it’s visible again.

Undo is plain prosemirror-history: the collab plugin marks remote transactions, so undo reverts only local edits and maps over remote commits.

CollabProvider

PropPurpose
docIdThe document. While undefined the provider renders loadingState
apiBasePathPrefix for the API; requests go to ${apiBasePath}/api/docs/:docId. Default ""
headersAdded to every request (doc, presence, comments). A function, sync or async, is called per request and never cached, so rotated tokens are picked up. If it throws or rejects, the request fails with that error (reported through onError) instead of going out unauthenticated
credentialsfetch credentials mode, e.g. "include" for cross-origin cookies
commentsfalse skips comment sync and wires noopCommentAdapter
loadingStateShown while docId is undefined. Default <EditorLoadingState />; null renders nothing
onErrorTelemetry sink

useCollabDoc() returns { docId, apiBasePath, fetch } for the nearest provider (throws outside one); useOptionalCollabDoc() returns undefined instead. fetch carries the provider’s headers and credentials.

RoomProvider and the proofread factories make their own requests: pass them the same headers and credentials.

currentUser comes from StesuraUserProvider. For demos, useGuestUser() generates an anonymous user; real apps pass their authenticated user. Pass { storageKey } to keep the same guest across reloads. A guest’s name and colour derive from its id, so guestProfile(id) in your resolveUsers shows every other guest the same way without a user directory.

Server

Two ways to run the backend: inside your Next.js app, or as a standalone Node server.

In a Next.js app

1. Create the tables. Run @stesura/collab-adapter-server/schema.sql once against your Postgres. It is idempotent and creates:

TableContents
collab_docOne row per document: compressed content + version
collab_commitCommit log. UNIQUE(doc_id, version) is the contention backstop: keep it
collab_threadOne row per comment thread
collab_room, collab_room_docRooms and their manifests, see Rooms

RLS is enabled with no policies on every table, as a deny-all backstop for other connections (e.g. Supabase’s PostgREST roles). The backend’s own connection is unaffected.

A database created before September 2026 stores collab_doc.content as jsonb. Re-running the schema doesn’t change column types, so migrate it once:

alter table collab_doc alter column content type bytea using convert_to(content::text, 'UTF8');

Old rows stay readable and are rewritten on their next save.

A database created before the collab_doc.incarnation column also needs it once. It tells a re-created document apart from the deleted one, so another server instance’s doc cache never serves the old content:

alter table collab_doc add column if not exists incarnation uuid not null default gen_random_uuid();

The same applies to form_doc if you use form-schema.sql. The self-hosted collab-server applies this as a migration on start.

2. Configure the backend (apps/demo/src/app/api/docs/_lib/backend.ts in the demo):

import "server-only"; import { createCollabBackend, postgresStore, redisBroadcast } from "@stesura/collab-adapter-server"; import { createInitialDoc } from "@stesura/core/helpers"; import { stesuraSchema } from "@stesura/core/schema"; const schema = stesuraSchema(); export const backend = createCollabBackend({ schema, store: postgresStore({ url: process.env.DATABASE_URL!, schema, // Optional. The default seed is schema.topNodeType.createAndFill(), which is sparser. createInitialDoc: (s) => createInitialDoc(s).toJSON(), }), broadcast: redisBroadcast({ url: process.env.REDIS_URL! }), });

The backend’s options (missingDocBehavior, maxContentBytes, commitRetention, docCache, serverPlugins, events) are covered in Backend API.

3. Mount the routes:

// app/api/docs/[docId]/[[...path]]/route.ts // docs: no-verify (getSession and canAccessDoc are your app's auth) import { createCollabRoutes } from "@stesura/collab-adapter-server/next"; import { backend } from "../../_lib/backend"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, { authorize: async (req, { docId }) => { const session = await getSession(req); return session ? canAccessDoc(session.userId, docId) : false; }, });

authorize is your document ACL and is required: the factory throws without it, unless you pass allowUnauthenticated: true (local demos only). It receives the request and { docId, path }, and returns true, false (403), or a Response to send as-is (e.g. a 401).

Standalone Node server

@stesura/collab-adapter-server/node serves the same routes from node:http, and adds CORS, a health check and client-address resolution:

import { createServer } from "node:http"; import { createCollabNodeHandler } from "@stesura/collab-adapter-server/node"; const handler = createCollabNodeHandler({ backend, docs: { authorize }, // mounted at /api/docs rooms: { authorize: authorizeRoom }, // mounted at /api/rooms; omit to skip rooms allowedOrigins: ["https://app.example.com", "https://*.example.com"], // sent with credentials trustedProxyHops: 1, // proxies in front of the server, for X-Forwarded-For }); createServer(handler).listen(3001);

allowedOrigins entries match exactly, except scheme://*.host[:port], which matches one subdomain label of host with the same scheme and port: https://*.example.com allows https://acme.example.com, not https://example.com or https://a.b.example.com. A predicate (origin) => boolean covers anything else, such as tenants looked up at request time.

healthPath defaults to /healthz (null disables it). On the client, point apiBasePath at the server’s origin (https://collab.example.com) and pass auth through headers or credentials.

X-Forwarded-For and X-Forwarded-Proto are read only within trustedProxyHops: the entry that many hops from the right, else the socket.

To serve your own routes beside the handler, the same /node entry exports the conversions it uses:

import { clientAddressOf, createCollabNodeHandler, sendWebResponse, toWebRequest, } from "@stesura/collab-adapter-server/node"; const collab = createCollabNodeHandler({ backend, docs: { authorize }, trustedProxyHops: 1 }); createServer(async (req, res) => { if (!req.url?.startsWith("/api/effects")) return collab(req, res); const request = toWebRequest(req, { trustedProxyHops: 1 }); // streamed body, proxy-aware URL const ip = clientAddressOf(req, 1); await sendWebResponse(res, await handleEffects(request, ip)); // streams, then ends `res` }).listen(3001);

@stesura/collab-adapter-server/route-kit holds the plumbing the collab routes are built from, for routes of your own that should behave the same:

import { createOriginMatcher, docResponse } from "@stesura/collab-adapter-server/route-kit"; const isAllowedOrigin = createOriginMatcher(["https://*.example.com"]); // as allowedOrigins // A JSON document with its version in headers, like the hydrate route. `schemaName` sets // `x-doc-schema`, `contentCoding` sends stored compressed bytes as-is; both optional. return docResponse(JSON.stringify(doc), { version, lastUpdatedTimestamp });

The wire constants (COLLAB_PROTOCOL_HEADER, PROTOCOL_MISMATCH_STATUS, …), the presence parsers and reportCollabError come from @stesura/collab-adapter/protocol, which loads no React, for server and client code alike. The main entry doesn’t re-export them.

For on-premise installs there is also a prebuilt server image (apps/collab-server) built on this handler. It is configured through environment variables, runs migrations on boot, and takes auth as an authorize module, a shared bearer secret, or an explicit opt-in to run open. See deploy/on-prem/INTEGRATING.md for setup.

Route options

export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, { authorize, // Presence identity from the session instead of the request body. null → 403. resolvePresenceUser: async (req) => (await getSession(req))?.userId ?? null, // The authenticated author, stamped onto mention events. resolveAuthor: async (req) => { const session = await getSession(req); return session ? { id: session.userId, name: session.name } : null; }, // Token bucket per actor on the write routes. rateLimit: { ratePerSecond: 20, burst: 60, identify: (req) => sessionUserId(req) }, });
  • rateLimit throttles commit, presence, thread and proofread writes after authorize, with a separate budget per route family. It is in-process, per instance: put a shared limiter at the edge for multi-instance deployments. Without identify the actor is the document plus the client address on /node, or the document alone on /next (which has no client address). All collaborators on a document then share one budget.
  • maxConcurrentPolls (default 16) caps held long-polls per actor, and maxConcurrentPollsPerDoc (default 512) per document. On /next the per-actor cap only applies when rateLimit.identify is set.
  • hydrationPassThrough (default true), see Storage compression.

Over budget, both limits answer 429 with Retry-After; clients back off and retry.

Telemetry

Errors the engine recovers from (a failed mention hook, a Redis reset, a commit that 500’d) only reach the console unless you pass a reporter:

export const backend = createCollabBackend({ schema, store, broadcast: redisBroadcast({ url: REDIS_URL, onError: reportToSentry }), onError: reportToSentry, // (error, { scope, docId, level, detail }) => void });

The route factories use the backend’s reporter. On the client, useCollabEditor, useLocalCollabEditor, CollabProvider, RoomProvider, LocalCommentProvider and the proofread factories take the same onError.

Connection notes

  • Postgres: any instance. On Supabase, use the transaction pooler URL (port 6543). postgresStore passes prepare: false by default, which that pooler needs. Driver options, such as the pool size (options: { max }), go in options.
  • Redis: Redis or Valkey 7.4 or later (presence uses hash-field TTLs). On Upstash, use the TCP rediss:// URL, not the REST API.

Custom stores

postgresStore is the only Postgres-specific code. To use another SQL database, implement CollabStore; its JSDoc states the correctness contract (atomic transactions, a row lock in getDoc, UNIQUE(doc_id, version) and UNIQUE(doc_id, ref)). The optional methods are performance hooks, see Backend API → Custom stores. tableNames renames the tables without a custom store. memoryStore implements the contract in memory, for tests.

Multiple backends (different schemas)

A backend is bound to one schema. To sync documents of a second schema (e.g. a form builder next to the editor), run a second backend on the same infrastructure with its own tables and mount:

// app/api/forms/_lib/form-backend.ts export const formBackend = createCollabBackend({ schema: formSchema, store: postgresStore({ url: process.env.DATABASE_URL!, schema: formSchema, tableNames: { doc: "form_doc", commit: "form_commit", thread: "form_thread" }, }), // Namespaces the presence, thread and proofread keys in the shared Redis. broadcast: redisBroadcast({ url: process.env.REDIS_URL!, keyPrefix: "forms" }), }); // app/forms/api/docs/[docId]/[[...path]]/route.ts export const { GET, POST, PUT, DELETE } = createCollabRoutes(formBackend, { authorize });

Run @stesura/collab-adapter-server/form-schema.sql once (the form_* doc, commit and thread tables; no rooms). On the client, point a CollabProvider at the second mount:

<CollabProvider docId={formDocId} apiBasePath="/forms"> <FormEditor /> {/* useCollabEditor({ schema: formSchema }) */} </CollabProvider>

Presence

Remote cursors and selections come from this workspace’s presence plugin, composed automatically (import @stesura/collab-adapter/styles.css once). It places each cursor once when it arrives and then maps it through later transactions, which stops cursors jumping.

  • A leaving client sends a leave signal (unmount, tab close), so its cursor disappears at once.
  • Indicators expire after 30 s without a refresh, for crashed clients (redisBroadcast({ presenceTTLSeconds })). An idle user’s cursor fades and returns on their next action.
  • Your own user’s cursors are never shown, including those from your other sessions.
  • A user with several sessions (tabs, devices) shows one cursor: the session that last moved or edited. The server times each move, so devices with skewed clocks still compare.
  • Colors are stable per userId and match the user’s track-change color. Names and avatars come from useCollabEditor({ getPresenceDisplayName, getPresenceAvatar }).

Who’s here

The remote carets are aria-hidden on purpose: they render inside the document, where labels would be read out as part of the text. Screen-reader and keyboard users need a presence list outside the document. CollabPresenceRoster is one:

import { CollabPresenceRoster } from "@stesura/collab-adapter-react"; <CollabPresenceRoster getDisplayName={(userId) => users[userId]?.name} maxVisible={4} />;

It renders a labelled list (you first, then each remote user once), sr-only names, a labelled “+N” overflow, and a polite live region announcing arrivals and departures. Its strings are in all 15 locales (t.collab.*). To build your own, use useDocPresence.

Connection & presence hooks

Both read the sync loops useCollabEditor already runs, so they report nothing until that document’s editor is mounted. docId defaults to the nearest CollabProvider’s.

  • useDocConnection(docId?) → "connecting" | "online" | "offline". <CollabConnectionStatus /> renders it as a status-bar dot + label (nothing outside a CollabProvider); the demo mounts it through a UI extension’s StatusBarElements.
  • useDocPresence(docId?) → the raw presence snapshot, keyed by clientId. Nothing is filtered, including your own other sessions: dedupe by userId.

Failure containment

The comments and presence UI render data other people wrote. Wrap that subtree, not the editor, in CollabErrorBoundary, so a bad thread or decoration shows a “collaboration unavailable” notice instead of breaking the page:

import { CollabErrorBoundary } from "@stesura/collab-adapter-react"; <CollabErrorBoundary docId={docId} onError={reportToSentry}> <CommentsPanel /> </CollabErrorBoundary>;

Pass fallback={({ error, reset }) => …} to render your own notice.

Comments

CollabProvider wires a CommentAdapter against the backend’s thread routes: threads, replies, delete/undo, resolve and reactions sync to collaborators within a poll cycle. useThreadSyncFailed(docId) turns true if sync stops for good (e.g. an auth failure), for an “out of date” banner.

Each thread is stored as one blob, last write wins. Two users editing the same thread at the same instant can lose one update.

Shared proofread cache

Proofreading caches results per paragraph. With createCollabProofreadCacheFactory the cache is stored in Redis per document, so reloads and other users reuse results instead of re-checking. Each client loads it once on mount; there is no live sync.

proofreadPlugin({ debounceTimeMS: 1000, generateProofreadErrors, createCache: createCollabProofreadCacheFactory({ docId, apiBasePath, headers }), parentEditorId, });

Writes are batched every 2 s. Entries expire 30 days after their last write (redisBroadcast({ proofreadTTLSeconds })); expiry only costs a re-check.

Deployment checklist

  1. Postgres: run schema.sql once.
  2. Redis 7.4 or later.
  3. DATABASE_URL and REDIS_URL.
  4. The backend config and the catch-all route (or the Node server).
  5. authorize wired to your ACL, and headers/credentials on the client if auth isn’t same-origin cookies.
  6. onError reporters on the backend and redisBroadcast.

Next steps

Last updated on