Collaborative editing — Yjs CRDTs
Two people typing into the same document at the same time is a genuinely hard problem,
and it's hard in a specific way: the moment both can edit, you have concurrent
changes that must merge without a server arbitrating every keystroke. This stack
solves it the modern way — with a CRDT (Yjs) — wrapped in the
small @tikab-interactive/fusion-collab package. Edits merge
mathematically, on every client, to the same result; the server is demoted to a dumb
relay.
Coming from React + Django? There's no stock Django answer to this one. The closest you'd assemble is Django Channels (for the WebSocket fan-out) plus a hand-rolled Operational Transformation layer — and OT is notoriously error-prone, because every operation has to be transformed against every concurrent operation in exactly the right order, usually with the server as the single source of truth doing that transformation. CRDTs take a different route entirely (next section). For a Django dev, the mental shift is: the merge logic does not live on the server. The server just rebroadcasts bytes — much like a Channels consumer that
group_sends to a room but never inspects the payload.
Like every optional capability here, it is env-gated — except the gate is unusually graceful: with no collab server configured, the editor still works, fully, as a local document. It just doesn't sync. The app never depends on the collab server being up.
What a CRDT is, for newcomers
CRDT stands for Conflict-free Replicated Data Type. The idea in one sentence: it's a data structure designed so that concurrent edits always merge to the same state, no matter what order they arrive in, with no central coordinator deciding who wins.
The trick is that an edit isn't "insert 'x' at position 5" (a position that shifts the instant anyone else types). Instead every character gets a unique, immutable id, and an insert says "put this character after that one." Two people inserting at "the same place" produce two operations that both reference a stable neighbor, so when each client receives the other's operation, both independently arrive at the identical ordering. Deletions are tombstones, not gaps. The upshot — the three properties that make merges trivial:
- Commutative — apply A then B, or B then A, same result.
- Associative / idempotent — re-receiving an update you already have is a no-op.
- Order-independent — updates can arrive late, out of order, or twice, and converge anyway.
Yjs is a battle-tested implementation of this for rich-text and structured data. You
get a Y.Doc (the replicated document), shared types inside it (Y.Text,
Y.XmlFragment, Y.Map…), and an update protocol: any change produces a compact
binary update; apply that update to any other Y.Doc and it converges. The unit test
in fusion-collab/src/collab.test.ts shows exactly this with no network at all — two
local docs reconciled by hand:
// fusion-collab/src/collab.test.ts (the CRDT convergence, distilled)
a.doc.getText("x").insert(0, "from-a");
Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc));
expect(b.doc.getText("x").toString()).toBe("from-a"); // b converged to a's stateThat's the whole CRDT promise in three lines: serialize one doc's state, apply it to another, they're identical. A WebSocket provider just automates that exchange across the network, continuously.
The package is provider + binding, nothing else
fusion-collab is deliberately thin — like realtime, it is pure
transport + binding, with no app types, no UI strings, and no schema. You bring the
editor and the document shape; the package gives you two functions. It's short enough
to read whole:
import { defineYjs, type YjsExtension } from "prosekit/extensions/yjs";
import { Awareness } from "y-protocols/awareness";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";
/**
* @tikab-interactive/fusion-collab — collaborative editing (the Channels-collab / Yjs counterpart),
* the transport half only. A CRDT document (Yjs) syncs between clients over a
* websocket provider; the editor binding is ProseKit's `defineYjs`. The
* consumer brings the schema and the editor UI — this package is provider +
* binding, no app types, no strings.
*
* Graceful degradation: with no server URL the session is a **local** Yjs doc
* (the editor still works, just doesn't sync), so the app never depends on the
* collab server being up.
*/
export interface CollabUser {
/** Stable id; drives the remote-cursor identity. */
id: string;
name: string;
/** Cursor/selection color (any CSS color). */
color?: string;
}
export interface CollabSession {
doc: Y.Doc;
awareness: Awareness;
/** The shared fragment ProseKit binds to. */
fragment: Y.XmlFragment;
/** null when running local-only (no server URL). */
provider: WebsocketProvider | null;
/** Subscribe to connect/disconnect; returns an unsubscribe. */
onStatus: (cb: (connected: boolean) => void) => () => void;
/** Tear down the provider + doc. Call on unmount. */
destroy: () => void;
}
/**
* Open a collaborative session for a room. With `url` set, edits sync through
* the websocket provider; without it the session is a standalone local doc.
* `user` is published to awareness so peers can render the remote cursor.
*/
export function createCollabSession(opts: {
url?: string | null;
room: string;
user: CollabUser;
}): CollabSession {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("prosemirror");
let provider: WebsocketProvider | null = null;
let awareness: Awareness;
if (opts.url) {
provider = new WebsocketProvider(opts.url, opts.room, doc);
awareness = provider.awareness;
} else {
awareness = new Awareness(doc);
}
awareness.setLocalStateField("user", {
name: opts.user.name,
color: opts.user.color ?? "#e8a33d",
});
return {
doc,
awareness,
fragment,
provider,
onStatus: (cb) => {
if (!provider) {
cb(false);
return () => {};
}
const handler = (event: { status: string }) => cb(event.status === "connected");
provider.on("status", handler);
return () => provider?.off("status", handler);
},
destroy: () => {
provider?.destroy();
doc.destroy();
},
};
}
/**
* The ProseKit extension that binds an editor to the session's Yjs document:
* sync + collaborative undo + remote cursors. Union it with your schema
* extension — and DROP `defineHistory`, Yjs owns undo here.
*/
export function defineCollab(session: CollabSession): YjsExtension {
return defineYjs({ doc: session.doc, awareness: session.awareness, fragment: session.fragment });
}Three pieces do all the work:
- a
Y.Doc— the replicated CRDT document. - a provider —
y-websocket, which syncs that doc with everyone else connected to the sameroomover a WebSocket. - an editor binding — ProseKit's
defineYjs, which wires the doc's sharedXmlFragmentinto a live ProseKit editor (sync + collaborative undo + remote cursors).
createCollabSession assembles the first two; defineCollab is the third. Everything
else — the schema, the toolbar, the React component — is the consumer's.
createCollabSession — the document and its transport
createCollabSession({ url, room, user }) opens a Y.Doc, grabs the shared
XmlFragment ProseKit will bind to (doc.getXmlFragment("prosemirror")), and then
branches on whether a server URL was given:
urlset → it constructs aWebsocketProvider(url, room, doc). From that moment the doc syncs: every local edit is broadcast to the room, every remote edit is merged in. The provider owns the awareness channel too.urlnull/absent → no provider. The session is a standalone localY.Docwith a free-standingAwareness. The editor is fully functional; it simply has no peers.
That single branch is the graceful-degradation story (see below).
The returned CollabSession also carries onStatus (subscribe to connect/disconnect)
and destroy (tear down the provider and doc — call it on unmount or you leak a
socket).
Awareness — the cursors and presence
Awareness is the part that makes collaboration feel live: the colored remote cursors, the selections, the "who's here" presence. It's deliberately separate from the document because it's ephemeral — it's the transient "where is everyone looking right now" state, not durable content, so it isn't part of the CRDT history and vanishes when you disconnect.
createCollabSession publishes the local user into awareness so peers can render the
cursor with the right name and color:
// fusion-collab/src/index.ts — local presence published to peers
awareness.setLocalStateField("user", {
name: opts.user.name,
color: opts.user.color ?? "#e8a33d", // the PulsN gold, by default
});When there's a provider, this awareness is the provider's awareness (so it's broadcast);
local-only, it's a standalone Awareness that no one else ever sees — same API either
way, which is why the editor code doesn't care which mode it's in.
defineCollab — binding the editor to the doc
defineCollab(session) returns the ProseKit extension that ties the editor to the
session's Yjs doc — sync, collaborative undo, and remote-cursor rendering, all from
defineYjs({ doc, awareness, fragment }). There's one rule the docstring is emphatic
about:
Drop the editor's own history extension. Yjs owns undo in a collaborative doc —
defineYjsprovides collaborative undo that only reverts your changes, not your collaborator's. If you also union in ProseKit'sdefineHistory, the two fight over the undo stack. UniondefineCollab(session)with your schema extension; do not add a history extension.
How a live edit flows
Five moving parts: two browsers, two Y.Docs, and the y-websocket relay between them.
The key idea, and the reason CRDTs beat OT here: the server does no merging. It
receives a binary update and rebroadcasts it. All the convergence math happens
independently in each browser's Y.Doc. That's also why the same code works offline —
take the relay away and each doc is simply a CRDT that never receives remote updates.
Graceful degradation
There is no boolean isCollabEnabled() here — the gate is the presence of a URL,
resolved per session. The example surfaces it through one server function that exposes
exactly one env var:
import { createServerFn } from "@tanstack/react-start";
import { getSession } from "#/lib/auth";
/** Shape returned to the client so it knows whether a WebSocket server is configured. */
export type CollabConfig = { wsUrl: string | null };
/**
* Returns the WebSocket URL for the collab server (or null when none is
* configured). The value of COLLAB_WS_URL is intentionally the ONLY env var
* exposed here — other server secrets never reach the client.
*
* With wsUrl null the client falls back to a local in-memory Yjs doc (no
* network connection required, no errors).
*/
export const getCollabConfig = createServerFn({ method: "GET" }).handler(
async (): Promise<CollabConfig> => {
const session = await getSession();
if (!session) throw new Response("Unauthorized", { status: 401 });
return { wsUrl: process.env.COLLAB_WS_URL ?? null } as const;
},
);COLLAB_WS_URL set → createCollabSession builds a provider and the doc syncs.
COLLAB_WS_URL unset → wsUrl is null, the session is local-only, and:
- the editor still works — you can type, undo, format; it's a real Yjs doc, just without peers.
onStatusreportsfalse(never connected), so the UI shows an "offline / local" badge instead of "synced".- the app never depends on the collab server — no failed-connection errors, no blocked render. A down (or absent) relay degrades editing to single-player, nothing more.
Coming from Django? Closest instinct: a Channels app that runs fine under plain WSGI with no channel layer — you just lose the realtime fan-out. Here it's even softer, because the document model is client-side: with no relay you don't merely lose broadcast, you still have a fully working local editor. The CRDT doesn't need the server to function, only to share.
The browser never sees the server half — and SSR never sees collab
fusion-collab is browser-only: it imports prosekit, yjs, y-websocket, and
y-protocols at module top level, all of which touch browser globals or open a
WebSocket. Server-rendering that import would crash. So, like its
realtime sibling, it ships a server/SSR
stub wired into the export conditions — every export throws at call time so server
code can conditionally import the module without exploding at import:
/**
* Server/SSR stub for @tikab-interactive/fusion-collab.
*
* Collaborative editing (Yjs + y-websocket + ProseKit) is browser-only: it
* opens a WebSocket and binds to a live editor instance. This stub is resolved
* by Node/SSR bundlers so that importing the package on the server does not
* pull in WebSocket or DOM dependencies.
*
* All exports throw at call-time (not import-time), which lets server code
* safely tree-shake or conditionally import the module. Use the real entry
* point (@tikab-interactive/fusion-collab) only in browser/client code.
*/
function browserOnly(): never {
throw new Error(
"@tikab-interactive/fusion-collab is browser-only. Import it in client code, not in server/SSR modules.",
);
}
export type CollabUser = {
id: string;
name: string;
color?: string;
};
export type CollabSession = {
doc: unknown;
awareness: unknown;
fragment: unknown;
provider: unknown;
onStatus: (cb: (connected: boolean) => void) => () => void;
destroy: () => void;
};
export const createCollabSession: (...args: unknown[]) => CollabSession = browserOnly;
export const defineCollab: (...args: unknown[]) => unknown = browserOnly;But the stub is a backstop, not the primary defense. The real discipline is on the
consumer side: the editor component is never statically imported into a route. It's
React.lazy()'d and rendered only after the client has mounted — the exact same
pattern the GIS map and realtime demo
use for browser-only libraries.
Where the example demos it
The sandbox collab page is a working two-pane editor: open it in two
windows and watch text sync. It's also a compact lesson in keeping a browser-only
library out of SSR.
The route (example/src/routes/sandbox.collab.tsx, paraphrased here because vocs can't
resolve an include under sandbox) does three things — fetch the config, gate on
mounted, and lazy-load the editor so its top-level yjs/y-websocket imports never
reach the server bundle:
// example/src/routes/sandbox.collab.tsx (excerpt)
// CollabEditor pulls in prosekit + yjs + y-websocket at module top level,
// so it must NEVER reach SSR — load it lazily, render only after mount.
const CollabEditor = lazy(() => import("#/components/CollabEditor"));
function CollabSandboxPage() {
const [mounted, setMounted] = useState(false);
const [wsUrl, setWsUrl] = useState<string | null>(); // undefined = loading; null = local-only
useEffect(() => setMounted(true), []);
useEffect(() => void getCollabConfig().then((cfg) => setWsUrl(cfg.wsUrl)), []);
return (
<Suspense fallback={<Skeleton h={180} />}>
{mounted && wsUrl !== undefined ? <CollabEditor wsUrl={wsUrl} /> : <Skeleton h={180} />}
</Suspense>
);
}The editor component itself (example/src/components/CollabEditor.tsx) is the canonical
consumer of the package — it owns the session lifecycle, binds the shared Y.Text to
React state, tracks connection status, and tears the session down on unmount:
import { useEffect, useRef, useState } from "react";
import { Badge, Stack, Text, Textarea } from "@tikab-interactive/fusion-ui/mantine";
import { createCollabSession } from "@tikab-interactive/fusion-collab";
import type { CollabSession } from "@tikab-interactive/fusion-collab";
import { testIds } from "@tikab-interactive/fusion-ui/testIds";
import { m } from "#/paraglide/messages.js";
// This module is loaded LAZILY from the route (client-side only) so that
// fusion-collab's module-level imports (prosekit, yjs, y-websocket) never
// reach the SSR bundle.
interface Props {
wsUrl: string | null;
}
/**
* A collaborative text editor backed by a Yjs shared document.
*
* When wsUrl is null the session uses a local in-memory doc — no network
* connection is required and no errors are thrown. When wsUrl is set the doc
* syncs live across all connected clients via a WebSocket provider.
*/
export default function CollabEditor({ wsUrl }: Props) {
const sessionRef = useRef<CollabSession | null>(null);
const [value, setValue] = useState("");
const [connected, setConnected] = useState(false);
useEffect(() => {
const session = createCollabSession({
url: wsUrl,
room: "sandbox-demo",
user: {
id: `web-${Math.floor(Math.random() * 1e6)}`,
name: "You",
color: "#e8a33d",
},
});
sessionRef.current = session;
// Bind the shared Y.Text to local React state.
const ytext = session.doc.getText("demo");
const sync = () => setValue(ytext.toString());
ytext.observe(sync);
sync();
// Track WebSocket connection status.
const unsubscribeStatus = session.onStatus(setConnected);
return () => {
ytext.unobserve(sync);
unsubscribeStatus();
session.destroy();
sessionRef.current = null;
};
}, [wsUrl]);
function handleChange(next: string) {
const session = sessionRef.current;
if (!session) return;
session.doc.transact(() => {
const ytext = session.doc.getText("demo");
ytext.delete(0, ytext.length);
ytext.insert(0, next);
});
}
return (
<Stack gap="sm">
<Badge color={connected ? "green" : "gray"} data-testid={testIds.sandbox.collab.status}>
{connected ? m.collab_status_synced() : m.collab_status_local()}
</Badge>
<Textarea
autosize
minRows={6}
value={value}
onChange={(e) => handleChange(e.currentTarget.value)}
placeholder={m.collab_placeholder()}
data-testid={testIds.sandbox.collab.textarea}
/>
<Text size="sm" c="dimmed">
{wsUrl === null ? m.collab_hint_local() : m.collab_hint_connected({ wsUrl })}
</Text>
</Stack>
);
}Two details worth internalizing from that component:
- The session is created in an effect keyed on
wsUrland destroyed in the cleanup. That's the whole lifecycle: mount →createCollabSession→ bind → on unmount →unobserve+unsubscribe+session.destroy(). Skip the teardown and you leak a socket per navigation. - Writes go through
doc.transact. Mutating the sharedY.Textinside a transaction batches the change into one CRDT update (and one undo step) rather than a flurry of per-character operations. Theobservecallback then pushes the converged text back into React state — so remote edits and local edits flow through the exact same path.
Coming from React? The
lazy(() => import(...))+mountedgate is this stack's standard fix for "this library only runs in the browser." You'll see it for every raw WebSocket or DOM-bound dependency — collab, the realtime demo, the GIS map. Nothing browser-only is ever imported at a route's module top level.
Running it locally
The collab WebSocket relay is an external service — it is not baked into the
example's docker-compose.yml. Run any y-websocket server (the reference
implementation is npx y-websocket, or any compatible relay) and point the app at it
with one var in .env.local:
COLLAB_WS_URL=ws://localhost:1234 # browser → y-websocket relayRestart the dev server, open /sandbox/collab in two browser windows, and type — the
text syncs and each window shows the other's cursor. Leave COLLAB_WS_URL unset and the
same page is a perfectly good single-player editor. Production wiring lives in
Deploy.
Where to go next
- Realtime — the sibling transport package; collab is its shared-document cousin. Both ship a browser/server stub and degrade to a no-op when unconfigured.
- Packages —
fusion-collabamong the env-gated graceful-degradation seams, and where it sits in the tiers. - Sandbox — the live two-pane
collabdemo. - Deploy — running a
y-websocketrelay in production. - Coming from Django — why collaborative editing is the one capability without a clean Django analogue (Channels + OT is the nearest miss).