Your schema already typed the backend.
Now it builds the UI.

Every Voltro procedure carries an effect/Schema — input, output, and the table it writes. Schema-driven UI projects that into the frontend: a form is a binding to a mutation, a table is a binding to a query. You get fields, columns, validation, and live auto-optimistic updates without writing the glue. And because the binding is a reactive, auto-optimistic backend — not a dead REST schema — the generated table is a live subscription, not a grid you refetch.

todos.tsx
TypeScript
// The form IS the mutation. Fields come from its input Schema;
// validation, submit, and op-correct optimistic are wired for you.
import { AutoForm, DataTable } from '@voltro/web'

// create-vs-edit is just two mutations — no "CRUD mode" switch
<AutoForm api="app" mutation="todos.create" />
<AutoForm api="app" mutation="todos.update" defaults={row} />

// The table IS the query. Columns from its output Schema; rows are a
// LIVE subscription — they update on any write, with no refetch.
<DataTable
  api="app"
  query="todos.list"
  rowActions={(r) => <button onClick={() => del.mutate({ id: r.id })}>Delete</button>}
/>

The descriptor is the single source of truth.

You already declared the input Schema, the output Schema, and the target table once, on the server. Schema-driven UI reads those — it never asks you to re-describe your data in a form config. Annotation customizes how a field renders; it never re-plumbs what the field is bound to.

Forms bind to a mutation

AutoForm reads the mutation's input Schema and renders the fields: a Literal union → a select, a string → text, a boolean → a checkbox. Validation, submit, and op-correct auto-optimistic come for free. Client and server share the exact same Schema.

Tables bind to a query

DataTable reads the query's output Schema for columns and subscribes for rows. The list updates on every write — no refetch, no cache to invalidate. pageSize opts into a live grow-the-window "Load more" that keeps streaming.

Pickers bind to a query

A reference() column defaults to a debounced, live typeahead off the conventional search query. AsyncSelect (or the headless useQueryField) gives you create-on-the-fly comboboxes with zero config — the option list is itself reactive.

A customization ladder, not a cliff

Annotate a field → render-prop one widget → swap a widget kind app-wide → own the whole layout → eject to the headless hook. On NO rung do you lose the binding. This is where every other schema→UI generator dies; here it's the design.

Reactive components, not just CRUD

Drop-in UI for the durable + reactive backend: WorkflowProgress for a live step timeline, PresenceAvatars / EditingIndicator for multiplayer, AgentChat for a reconnect-surviving AI chat. Each has a headless hook underneath.

A toolbox of bound hooks

useCan (gate on the same scopes the server enforces), usePreview (dry-run a mutation in a rolled-back transaction), useUndo (universal Ctrl-Z over mutations), useAsyncValidation, RecordView, a typed analytics catalog, an offline outbox, windowed subscriptions. All projections of the same descriptor graph.

Sugar on top, headless underneath.

headless.tsx
TypeScript
// Every component is sugar over a headless binding. Eject from the
// RENDERING without losing the binding (schema + submit + optimistic).
import { useFormBinding, useDataTable } from '@voltro/client'

const form  = useFormBinding('app', 'todos.create')
// → { fields, values, errors, isValid, pending, setValue, submit, reset }

const table = useDataTable('app', 'todos.list', { pageSize: 25 })
// → { columns, rows, loading, sort, toggleSort, loadMore, hasMore }
// rows stay reactive as the window grows. Render any way you like.

Why the generated version actually beats the hand-written one.

Auto-generated CRUD usually fails because it generates against a dead REST/SQL schema and hits a wall the moment you need anything custom. Voltro's version is different on one axis: the generated UI binds to a reactive, auto-optimistic runtime. The generation is the delivery; the reactivity is the value.

One change, two surfaces:

Add a column to the table → the entity Schema changes → the mutation's input Schema changes → AutoForm grows the field AND DataTable grows the column, both typed, both validated, on the next reload. No form library to update, no column config to sync, no optimistic reducer to hand-write. Eject to the headless hook the day you outgrow the defaults — the binding (schema + submit + live updates) survives the eject.

It composes with the rest of the runtime.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.