Fullstack app
The reactive end-to-end loop in one page — a web frontend wired to an api. A live list (useSubscription) + a create form (useMutation) with zero-boilerplate auto-optimistic updates.
The framework's headline feature — a web client subscribing to a server query and getting WebSocket deltas on every write — in one page. frontend-app is the only template that wires a web frontend to an api; every other frontend-* template is backend-less and every api-* template is frontend-less. Template id: frontend-app.
Scaffold the pair
frontend-app consumes a sibling api, so scaffold it together with api-backend — which exposes the notes.list query + notes.create mutation this page uses:
voltro create-project demo --api=api-backend --web=frontend-app
cd demo
pnpm install
pnpm dev # boots BOTH apps — the api and the webAdd a note and it appears in the list instantly — no refetch, no polling. Open a second tab: a note added in one shows up live in the other.
What ships
apps/demo/web/
├── app.config.ts # type:web + the apis: { app } wiring
├── package.json # depends on @demo/api (the sibling) + @voltro/client
├── tsconfig.json
├── README.md
└── src/pages/
├── layout.tsx # root shell (header + <main>)
└── index.tsx # the reactive page — useSubscription + useMutationThe apis wiring
A web app declares the apis it consumes in app.config.ts. The map KEY is the lookup name every hook takes as its first argument:
// app.config.ts
export default {
type: 'web' as const,
name: 'DemoWeb',
apis: {
app: { package: '@demo/api' }, // the sibling api in this project
},
}The package resolves the api workspace package; its port is auto-discovered from its own app.config.ts, and codegen pulls its typed rpc surface so the hooks are end-to-end typed. Add more entries to consume more apis — each gets its own WebSocket + reconnect cycle, independent of the others.
// package.json — the web depends on the sibling api as a workspace package
"dependencies": {
"@demo/api": "workspace:*",
"@voltro/client": "workspace:*",
"@voltro/web": "workspace:*"
}The reactive loop
// src/pages/index.tsx
import { useMutation, useSubscription } from '@voltro/client'
const TENANT = 'acme'
const IndexPage = () => {
// ONE live WebSocket subscription. `data` re-renders whenever the `notes`
// table changes — from this tab, another tab, a workflow, or a raw DB write.
const { data, revision, error, pendingPatches } =
useSubscription<ReadonlyArray<Note>>('app', 'notes.list')
// Invokes the mutation. Its descriptor declares `target: { table:'notes',
// op:'insert' }`, so the framework AUTO-PREPENDS an optimistic row the
// instant you submit — replaced by the real row (or reverted) on resolve.
const create = useMutation<{ tenantId: string; title: string; body: string }>('app', 'notes.create')
const notes = data ?? []
// … a form that calls create.mutate({ tenantId: TENANT, title, body }) …
}useSubscription('app', 'notes.list')opens the live feed. The first argument ('app') is the api NAME fromapp.config.ts; the second is the rpc tag. Two components subscribing with the same triple share ONE upstream subscription.useMutation('app', 'notes.create').mutate({...})writes. There is no.withOptimistic, nouseOptimistic, nostartTransition— the auto-optimistic patch comes from the mutation's server-sidetarget. Optimistic rows carryoptimistic: true; render them faintly until the server delta supersedes them.
The subscription builder also exposes revision, emittedAt, error, and pendingPatches for status UI; the mutation builder exposes pending, error, and data.
Pairing with a different api
This page is wired to api-backend's notes domain. To point it at your own api, change apis.app.package in app.config.ts, then swap the rpc tags + the row type in src/pages/index.tsx to match your descriptors. The hook shapes are identical for any api.
When to use
| You want… | Pick |
|---|---|
| To see the reactive loop / start a real fullstack app | frontend-app (+ api-backend) |
| A frontend with no backend | frontend-blank / frontend-landing |
| Just the backend | api-backend |
Anti-patterns
- Scaffolding
frontend-appwithout an api. It depends on the sibling@<project>/apiworkspace package —pnpm installfails without it. Always scaffold the pair (--api=api-backend --web=frontend-app). - Polling /
refetch. This framework is push-based; the subscription stays live for the component's lifetime. If you reach forsetInterval(() => refetch()), something is wrong with the subscription. - Hand-rolling optimistic updates. The
targeton the mutation descriptor drives the patch automatically. Only reach for.withOptimisticwhen the default insert/update/delete shape isn't what you want. - Trusting
x-tenantin production. The devAuthMiddlewarereads it unauthenticated. Wire a real resolver before shipping (see the authentication docs).