Skip to Content

Rooms & suites

A room groups related documents into a suite, e.g. every document generated from one form. The server keeps a manifest (docId, title, position per document). Clients long-poll it and mount only the document on screen, with its own CollabProvider + useCollabEditor.

RoomProvider (manifest long-poll) ├─ sidebar: useRoomManifest() → { docs, addDoc, removeDoc } └─ main area: ONE CollabProvider + editor for the selected doc

Don’t mount every document of a suite at once: each open document runs its own commit, presence and thread long-polls.

Server setup

Rooms use the collab_room and collab_room_doc tables from schema.sql (re-running it adds missing tables). Mount the room routes next to the doc routes:

// app/api/rooms/[roomId]/[[...path]]/route.ts import { createCollabRoomRoutes } from "@stesura/collab-adapter-server/next"; import { backend } from "../../docs/_lib/backend"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const { GET, POST, DELETE } = createCollabRoomRoutes(backend, { authorize: async (req, { roomId }) => { const session = await getSession(req); return session ? isMember(session.userId, roomId) : false; }, });

Like the doc routes, authorize is required (or allowUnauthenticated: true), and rateLimit throttles adding and removing documents.

Set the backend to reject missing documents, so a deleted doc answers 410 instead of being re-created by the next client that polls it:

export const backend = createCollabBackend({ schema, store: postgresStore({ url, schema }), broadcast: redisBroadcast({ url: REDIS_URL }), missingDocBehavior: "reject", });

Authorization

Room membership lives in your app, not in the library’s tables. Both route factories check it:

  • Room routes: authorize gets the roomId directly.
  • Doc routes: look up the room with backend.getRoomOfDoc(docId) (one indexed read), then check the same membership:
export const { GET, POST, PUT, DELETE } = createCollabRoutes(backend, { authorize: async (req, { docId }) => { const session = await getSession(req); if (!session) return false; const roomId = await backend.getRoomOfDoc(docId); return roomId ? isMember(session.userId, roomId) : false; }, });

Backend operations

MethodEffect
getRoomManifest(roomId){ ref, docs }: the manifest and its change token
listenForRoomManifest(roomId, knownRef)Long-poll; answers as soon as the ref differs
createRoomDoc(roomId, { docId, title, position, initialContent? })Creates the room if absent, seeds the doc and adds it to the manifest
removeRoomDoc(roomId, docId)Removes it from the manifest and deletes the document (doc, commits, threads)
getRoomOfDoc(docId)Doc → room, for authorize

Details and errors are on Backend API → Rooms.

Client

"use client"; import { CollabProvider, RoomProvider, useCollabEditor, useRoomManifest, } from "@stesura/collab-adapter-react"; function Sidebar({ onSelect }: { onSelect: (docId: string) => void }) { const { docs, loading, failed, addDoc } = useRoomManifest(); if (loading) return <Spinner />; return ( <nav> {failed && <p>This list is out of date. Reload the page.</p>} {docs.map((d) => ( <button key={d.docId} onClick={() => onSelect(d.docId)}>{d.title}</button> ))} <button onClick={() => addDoc({ docId: crypto.randomUUID(), title: "New", position: docs.length })} > Add </button> </nav> ); } function SuiteEditor() { const { editorState, dispatch, pluginFactory, schema, docStatus } = useCollabEditor(); if (docStatus === "gone") return <p>This document was removed from the suite.</p>; return <StesuraEditor state={editorState} dispatchTransaction={dispatch} /* … */ />; } export default function SuitePage({ roomId }: { roomId: string }) { const [selected, setSelected] = useState<string | null>(null); return ( <RoomProvider roomId={roomId}> <Sidebar onSelect={setSelected} /> {/* key remounts the editor per document: only the selected one is connected */} {selected && ( <CollabProvider key={selected} docId={selected}> <SuiteEditor /> </CollabProvider> )} </RoomProvider> ); }
  • RoomProvider takes roomId, apiBasePath, headers, credentials and onError. They apply to room requests only: pass them to each CollabProvider too.
  • useRoomManifest() returns { docs, loading, failed, addDoc, removeDoc }. docs updates within a poll cycle when anyone adds or removes a document, your own changes included. failed is true once polling has stopped for good (e.g. an auth failure).
  • addDoc({ docId, title, position, initialContent? }) and removeDoc(docId) return promises and throw on failure. The list updates through the poll, not optimistically.
  • useRoom() returns { roomId, apiBasePath }.
  • When a document is removed while open, the server answers 410 and docStatus turns "gone".

Template publishing

Suites are usually generated from a published snapshot of a template, not from the draft its author is still editing. Published versions are your app’s data, e.g.:

create table template_version ( template_doc_id text not null, version_label text not null, doc_json jsonb not null, doc_version integer not null, created_at timestamptz not null default now(), primary key (template_doc_id, version_label) );

Publishing reads the live template:

const snapshot = await backend.getInitialDoc(templateDocId); await db.insert("template_version", { template_doc_id: templateDocId, version_label: label, doc_json: snapshot.docJSON, doc_version: snapshot.version, });

Generating a suite seeds each document from the snapshot, then fills in fields with server edits:

export async function generateSuite(roomId: string, templateId: string, fields: Fields) { const { doc_json } = await getPublishedVersion(templateId); for (const [i, title] of ["Contract", "Annex A", "Cover letter"].entries()) { await backend.createRoomDoc(roomId, { docId: crypto.randomUUID(), title, position: i, initialContent: doc_json, }); } const { docs } = await backend.getRoomManifest(roomId); return backend.applyServerEditMany( docs.map(({ docId }) => ({ docId, build: setPlaceholders(fields), // pure, anchored by placeholder attrs opts: { mustExist: true }, })) ); // index-aligned; retry "error" and droppedSteps > 0 entries }

initialContent is ProseMirror JSON. Don’t build it from HTML on the server: createInitialDoc(schema, html) needs the DOM and throws in route handlers and server actions. For simple seeds use createDocContent(schema, blocks) from @stesura/core/helpers.

Scope

  • Presence and comment threads are per document.
  • Commits aren’t multiplexed across a room: with one open document there is nothing to multiplex.

Next steps

Last updated on