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/
    ├── 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
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 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.

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.