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 web

Add 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 + useMutation

The 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 }) …
}
  1. useSubscription('app', 'notes.list') opens the live feed. The first argument ('app') is the api NAME from app.config.ts; the second is the rpc tag. Two components subscribing with the same triple share ONE upstream subscription.
  2. useMutation('app', 'notes.create').mutate({...}) writes. There is no .withOptimistic, no useOptimistic, no startTransition — the auto-optimistic patch comes from the mutation's server-side target. Optimistic rows carry optimistic: 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-app without an api. It depends on the sibling @<project>/api workspace package — pnpm install fails 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 for setInterval(() => refetch()), something is wrong with the subscription.
  • Hand-rolling optimistic updates. The target on the mutation descriptor drives the patch automatically. Only reach for .withOptimistic when the default insert/update/delete shape isn't what you want.
  • Trusting x-tenant in production. The dev AuthMiddleware reads it unauthenticated. Wire a real resolver before shipping (see the authentication docs).