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 web

Open 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/
    ├── lib/offline.ts             # the mirror source + the durable store opener
    ├── lib/offlineStorage.hook.ts # the store as React state
    ├── locales/{en,de}.ts         # bilingual UI strings
    └── pages/
        ├── layout.tsx             # root shell (header + <main> + <LocaleSwitcher>)
        └── page.tsx               # the editor — subscription + CRDT handle + setBody

The 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
import { documentsMirror } from './src/lib/offline'
export default {
  type: 'web' as const,
  name: 'CollabWeb',
  apis: {
    app: { package: '@collab/api', headers: { 'x-tenant': 'acme' }, mirror: documentsMirror },   // the sibling api-collab + the offline mirror
  },
}

The api has no sign-in and takes the tenant from the caller's x-tenant header (auth.anonymous: 'header'), so the web app sends it as a static header on every request. 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 IndexPage
  1. useSubscription('app', 'documents.list') streams the shared document, including its merged body, on every write.
  2. Each keystroke applies to the local handle and sends handle.encode() via useMutation('app', 'documents.setBody') — no .withOptimistic, no polling.
  3. The api's authoritative server-side merge folds the update into the stored state before broadcasting, so concurrent edits converge.
  4. 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.

Offline

Turn the network off, or stop the api, and keep working — the page wires the whole local-first offline story:

  • The document list renders from the mirror. app.config.ts hands the api mount documentsMirror (src/lib/offline.ts), so every documents.list delivery is kept in IndexedDB; a reload while the api is unreachable still shows the documents, and the next connect resumes with deltas. warm keeps the list subscribed before the page asks.
  • Edits survive closing the tab. useCrdtDoc gets a durable adapter; unsent edits reappear in the editor on reopen and go out when the api is back.
  • "Create document" goes through useOutbox({ api: 'app' }). Queued while offline, shown in the list at once, kept across a reload, sent once.
  • The page knows when a write cannot land — useApiOnline('app') drives the offline line and the count of waiting writes.

The demo has no sign-in, so the mirror's partition is the anonymous subject of acme. With auth, call documentsMirror.remember(...) after a sign-in and forget() on sign-out, each before useReconnect(). The template maintainers prove this loop in a real browser with scripts/collab-offline-proof.mjs.

Running on Loro instead of Yjs

The pair merges with Yjs by default; each app chooses its backend in ONE file, and both must agree (the backends' bytes do not mix):

// api: lib/crdt.ts — read by `crdt.backend` in app.config.ts
import type { CrdtBackend } from '@voltro/local-first'
import { loroBackend } from '@voltro/local-first/loro'

export const crdtBackend: CrdtBackend = loroBackend
// web: src/lib/crdt.ts — imported by app.config.ts, before the client mounts
import { setDefaultCrdtBackend } from '@voltro/local-first'
import { loroBackend } from '@voltro/local-first/loro'
import { loroEditorBinding } from '@voltro/local-first/loro-editor'
import type { CrdtEditorBinding } from '@voltro/local-first/editor'

setDefaultCrdtBackend(loroBackend)
export const editorBinding: CrdtEditorBinding = loroEditorBinding

Add loro-crdt to both packages and loro-prosemirror to the web app. Switch before the column holds documents — a stored Yjs document does not open on Loro. The offline loop above runs unchanged on Loro; the template maintainers prove it with collab-offline-proof.mjs --backend=loro. In the browser, voltro dev and voltro build both load loro-crdt's browser build.

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-collab without api-collab. It depends on the sibling @<project>/api workspace package — pnpm install fails 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. usePresence needs a bound PresenceChannel; it is a documented seam, not part of the zero-infra text-convergence path.