Collaborative editor
A collaborative document editor — a textarea bound to a crdtText() body over the reactive engine. Type in two browser tabs at once and the edits converge (server-authoritative CRDT merge), no last-write-wins loser.
A live document editor whose textarea is bound to a crdtText() body over the framework's reactive loop. Type in two browser tabs at once and the edits converge — no last-write-wins loser — because the api merges every update into the shared document server-side, then broadcasts the merged result back live. Zero-infra: the merge and the broadcast are in-process on a single voltro dev. frontend-collab is a fullstack template — it consumes a sibling api. Template id: frontend-collab.
Scaffold the pair
frontend-collab consumes a sibling api, so scaffold it together with api-collab — which exposes the documents.list subscription + documents.create / documents.setBody mutations this page binds to:
voltro create-project collab --api=api-collab --web=frontend-collab
cd collab
pnpm install
pnpm dev # boots BOTH apps — the api and the webOpen the web app, click Create document, then open a second tab on the same URL and type in both. Every keystroke merges — both tabs' edits survive.
What ships
apps/collab/web/
├── app.config.ts # type:web + the apis: { app } wiring + locales
├── package.json # depends on @collab/api + @voltro/client + @voltro/local-first
└── src/
├── locales/{en,de}.ts # bilingual UI strings
└── pages/
├── layout.tsx # root shell (header + <main> + <LocaleSwitcher>)
└── page.tsx # the editor — subscription + CRDT handle + setBodyThe apis wiring
A web app declares the apis it consumes in app.config.ts. The map KEY (app) is the lookup name every hook takes as its first argument:
// app.config.ts
export default {
type: 'web' as const,
name: 'CollabWeb',
apis: {
app: { package: '@collab/api' }, // the sibling api-collab in this project
},
}The package resolves the sibling api workspace package; its port is auto-discovered, and codegen pulls its typed rpc surface so the hooks are end-to-end typed.
The collaborative loop
The page keeps a local crdtText() handle (@voltro/local-first) seeded from the server state. Each keystroke applies a contiguous insert/delete to that handle and sends handle.encode() via documents.setBody; every incoming server body is folded back into the handle (idempotent), keeping every tab in sync. The convergence is authoritative and server-side — the api merges each update into the stored state before broadcasting.
// src/pages/page.tsx
import { useMutation, useSubscription } from '@voltro/client'
import { useEffect, useRef, useState } from 'react'
import { crdtText, emptyCrdtState, type CrdtState, type CrdtText } from '@voltro/local-first'
interface DocRow {
readonly id: string
readonly title: string
readonly body: Uint8Array | null // encoded CRDT state (base64 on the wire)
}
const Editor = ({ doc, onUpdate }: {
readonly doc: DocRow
readonly onUpdate: (update: CrdtState) => void
}) => {
// One LOCAL CRDT handle per mount, seeded from the current server state.
const handleRef = useRef<CrdtText | null>(null)
if (handleRef.current === null) handleRef.current = crdtText().merge(doc.body ?? emptyCrdtState())
const [text, setText] = useState(() => handleRef.current!.toString())
// Fold every incoming server body back into the local handle (idempotent) —
// remote edits appear and converge.
useEffect(() => {
const handle = handleRef.current!
handle.merge(doc.body ?? emptyCrdtState())
setText(handle.toString())
}, [doc.body])
const onChange = (next: string) => {
const handle = handleRef.current!
// (diff prev → next into a contiguous insert/delete — see the shipped page)
setText(next)
onUpdate(handle.encode()) // full encoded state; the server folds it in
}
return <textarea value={text} onChange={(event) => onChange(event.target.value)} rows={12} />
}
const IndexPage = () => {
// ONE live subscription; `data` re-renders on every write (this tab or another).
const { data } = useSubscription<ReadonlyArray<DocRow>>('app', 'documents.list')
const setBody = useMutation<{ id: string; update: CrdtState }>('app', 'documents.setBody')
const doc = (data ?? [])[0]
if (doc === undefined) return null
// Remount (key={doc.id}) when the bound document changes, so the handle's
// lifecycle is scoped to it.
return <Editor key={doc.id} doc={doc} onUpdate={(update) => setBody.mutate({ id: doc.id, update })} />
}
export default IndexPageuseSubscription('app', 'documents.list')streams the shared document, including its mergedbody, on every write.- Each keystroke applies to the local handle and sends
handle.encode()viauseMutation('app', 'documents.setBody')— no.withOptimistic, no polling. - The api's authoritative server-side merge folds the update into the stored state before broadcasting, so concurrent edits converge.
- Incoming server bodies are folded back into the handle (idempotent), keeping every tab in sync.
This page is exactly the app-level binding the local-first docs describe as the SyncClient transport: push is the setBody mutation, onRemoteState is the documents subscription.
Not wired here: presence cursors
Live "who else is editing" cursors (usePresence from @voltro/local-first/react) need a PresenceChannel bound to the app's broadcast broker — a runtime binding, not zero-infra in a single dev process across tabs — so this template omits them. The CRDT text convergence above needs none of that; it rides the reactive engine you already have. Wire presence when you add the broker binding.
Bilingual by default
Like every frontend template, frontend-collab ships en/de with cookie i18n: nav labels and page copy come from src/locales/{en,de}.ts via <T> / useT(), and <LocaleSwitcher> writes the voltro:locale cookie. The de.ts catalog mirrors every key in en.ts — a missing translation fails typecheck.
When to use
| You want… | Pick |
|---|---|
| A collaborative / local-first editor over the reactive loop | frontend-collab (+ api-collab) |
| The plain reactive list+form loop (no CRDT) | frontend-app (+ api-backend) |
| Just the CRDT backend | api-collab |
Anti-patterns
- Scaffolding
frontend-collabwithoutapi-collab. It depends on the sibling@<project>/apiworkspace package —pnpm installfails without it. Always scaffold the pair. - Binding the textarea to the raw server bytes. Bind it to the local handle's string; fold incoming server bodies into that handle. Re-seeding the handle on every delta would drop in-flight local keystrokes.
- Sending the whole textarea value as text. Send the handle's
encode()— the encoded CRDT state is what the server merges. Overwriting with plain text throws away the convergence guarantee. - Reaching for presence before the broker exists.
usePresenceneeds a boundPresenceChannel; it is a documented seam, not part of the zero-infra text-convergence path.