QUESTPIE
Client

Collaborative documents

A typed handle onto one collaborative record. Build it from the client you already have, connect it, and write into a field while somebody else writes into the same one.

View markdown

You marked the fields in the schema. Nothing in the browser uses them yet. Everything below runs there, and none of it starts until you call connect().

Build the CRDT client

src/lib/crdt.ts
import { createCrdtClient } from "questpie/crdt";
import { client } from "./client";

export const crdt = createCrdtClient(client);

createCrdtClient takes the client you already built and reuses its realtime session. A collaborative app still holds exactly one connection. If that connection started for a live query without CRDT authority, the first document connection replaces the edge once and carries its query subscriptions onto the CRDT-capable session. createClient() does not build this API for you. There is no client.crdt either, and reading that property throws a message pointing you back here.

Build it once and pass it around. Each call gets its own scheduler. That scheduler bounds pulls and awareness writes across every open document.

Open a document

const article = crdt.collections.articles.document({ id: "article-1" });

await article.connect({ mode: "edit", fallback: "view" });

A collection takes { id }. A global takes nothing. Every call to document() builds a new handle. Keep the one you got. Do not build a fresh one per render.

Constructing the handle does nothing. No request, no IndexedDB. Both start at connect(). That is what makes a handle safe to build during SSR.

mode is "view" or "edit". fallback: "view" accepts a read-only result when edit access is refused. Without it, a refused edit rejects the whole connection. fallback is legal only alongside mode: "edit".

No engine means no connection

connect() needs an engine. Pass it to createCrdtClient() as runtime: { engines: { text: yjsClientEngine() } }. Leave it out and the call rejects with CRDT_UNAVAILABLE and the state goes to denied. It also has to match the engine the server registered.

Read and edit a field

const title = article.fields.title.text.value();

article.fields.title.text.apply([
	{ type: "insert", index: title.length, value: " together" },
]);

article.fields.tags.set.add("news");
article.fields.tags.set.delete("draft");

Which port a field has depends on the format you declared for it.

FormatCallWhat it does
text.text.value()the current string
text.text.apply(ops)inserts and deletes
text.anchors.create(input)a position that survives edits
text.anchors.resolve(token)where that position is now
set.set.values()every member, in byte order
set.set.has(value)membership
set.set.add(value)one add
set.set.delete(value)one remove
set.set.apply(ops)several at once
both.format"text" or "set"

A text operation is { type: "insert", index, value } or { type: "delete", index, length }. Indices are UTF-16 code units and may not split a surrogate pair. An insert value cannot be empty and cannot contain a NUL. Anything else throws CrdtMutationError with INVALID_OPERATION.

Every field key hands back a port, whether or not that field exists. The call is what throws. Reading before the first sync finishes throws CrdtReadError with NOT_READY. Writing into a field you only hold view on throws CrdtMutationError with FIELD_VIEW_ONLY. A field the server did not grant you throws FIELD_HIDDEN.

Change several fields at once

article.transaction(({ fields }) => {
	fields.title.text.apply([{ type: "insert", index: 0, value: "Shared: " }]);
	fields.tags.set.add("collaboration");
	fields.content.text.apply([
		{ type: "insert", index: 0, value: "First paragraph." },
	]);
});

All three edits leave as one bundle. The server appends every part or none of them.

The callback runs immediately and must return nothing. Return a promise and it throws ASYNC_TRANSACTION. Open a transaction inside a transaction and it throws NESTED_TRANSACTION. The outer one is dropped too. Anything invalid inside rejects the whole local bundle. One bundle carries at most 32 parts.

Watch the state

const unsubscribe = article.subscribe((state) => {
	if (state.status === "ready") render(state.fieldGrants);
	if (state.status === "recovery-required") showRecovery(state.reason);
});

The listener fires on lifecycle changes and on local edits. An edit publishes the same state object, so compare article.replicaRevision to tell the two apart. getSnapshot() reads the current state without subscribing.

statusReadWrite
idlenono
authorizing, connecting, synchronizingnono
readyyesyes
offlineyesyes
suspendedyesno
recovery-required, denied, failednono
closednono

ready carries fieldGrants, fieldSyncing and pendingUpdates. offline carries fieldGrants and pendingUpdates. denied and failed carry a code, and failed says whether it is retryable.

Disconnect and close

connect() counts references. Two calls need two await article.disconnect() calls before the transport comes down. Call your own unsubscribe() alongside the last one.

The last disconnect() leaves the handle at offline when it already read something. Otherwise it lands on idle. close() is terminal, and connect() after it rejects with CLOSED.

Awareness

article.awareness.set(
	{ name: "Ada" },
	{ activeField: "content", cursor: 12, selectionEnd: 20 },
);

const stopRoster = article.awareness.subscribe((participants) => {
	renderCollaborators(participants);
});

awareness.enabled is false unless the owner declared .collaborative({ awareness }). Calling set on an owner without it throws INVALID_OPERATION. Calling it before the document is ready throws NOT_READY.

The server parses your value with the Zod schema you declared. The browser caps it at 512 bytes of canonical JSON first. It also refuses the keys activeField, cursor and selectionEnd, because the protocol uses those itself.

activeField must name a text field you can read. The roster hands back each participant, their sessions, and each session's value, expiresAtMs and active. Cursor positions come back as offsets into your own copy of the text. clear() removes your entry, and getRoster() reads the last roster without subscribing.

All of this is plain TypeScript. No React, no Tiptap, no ProseMirror, no TanStack Query. Keep the handle where its lifetime belongs. Subscribe with your own state adapter and disconnect during cleanup.

The working result

Two people have the same article open. One types in the title while the other types in the body. Both see the other's characters arrive. Neither has to save. Neither overwrites the other. Every ordinary query you already wrote still reads the merged columns.

Where each topic lives

TopicPage
Positions that survive other people's editsText anchors
Editing with no network, and coming backOffline and recovery
Marking the fields in the first placeCollaborative documents
Routes, engines and protocol limitsRuntime and limits

Next

Collaborative article builds one of these end to end, from the collection file to the connected editor.

On this page