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.
// 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 — and on a server-rendered page the form works with JavaScript disabled: a native POST reaches the same mutation through the same validation, success redirects (PRG), errors re-render the page with the same field messages.
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.
// 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.The column decides, not the view.
// database/schema.ts — the one place the shape is stated
export const contacts = table('contacts', {
name: text(),
email: text().unique(),
plan: text().default('free'),
notes: text().nullable(),
apiKey: text().serverOnly(), // never reaches the browser
}).with(tenant(), audit())Neither component was told what the fields are. They read the declaration, so adding a column adds an input and a table column — and a column marked server-only never reaches the browser to be rendered by accident.
Schema-driven UI, in depth.
What does the schema actually generate?
Form bindings, table columns, filters, validation messages and the field types that go with them — derived from the same declaration the database and the api already use. A text column becomes a text input, an enum becomes a select, a nullable column becomes an optional field, and each carries the validation the server will apply anyway.
The value is not saving keystrokes. It is that the form cannot disagree with the server, because both read one declaration. Adding a required column makes the form require it without anybody remembering to; removing one removes it from the table rather than leaving a column of undefined.
It is derivation rather than generation into files. There is no generated component to regenerate and diff, and no ejection step where the connection to the schema is severed the first time you need something custom.
What happens when the generated component is not what I want?
You override the field and keep everything else. The binding gives you the field's value, its validation state and its change handler; how it renders is yours. A date field that needs a bespoke picker does not force the rest of the form back to hand-written.
That is the difference from a scaffolding generator, which gives you code once and then has nothing more to offer — the moment you edit it, it stops tracking the schema, and the drift starts on the same day.
It is also why the primitives are unstyled bindings rather than a component library you must adopt wholesale. Bring your own components; the framework supplies the wiring between a schema field and whatever renders it.
How does this interact with live data?
A table bound to a query is a subscription, so it updates when the underlying rows change — including from another user or another service — rather than after a refetch you scheduled. Sorting, filtering and pagination are expressed against the query rather than against a client-side copy.
Mutations bound to a form get the same auto-optimistic behaviour as any other mutation: the list patches before the round trip completes and reconciles when the server answers, without a hand-written optimistic updater per form.
For long lists there is a windowed subscription so only a slice is held in memory, which matters when the table is the product rather than a settings screen.
Is this an admin panel?
It is the layer an admin panel is built from, and one is included — a dashboard that reads your schema and gives you browsing, editing and inspection without you building CRUD screens for internal use.
But the same bindings serve customer-facing UI, which is the point. A generated admin panel that cannot be used for the real product means writing everything twice: once for staff, once for users, with two sets of validation that drift.
The access decisions apply either way. A field the subject may not read is not rendered because it never crosses the wire, and a server-only column cannot reach a form at all — the UI inherits the boundary rather than re-implementing it.
What the schema drives.
| Surface | Derived from |
|---|---|
| Form fields | Column types and nullability — with the same validation the server applies, not a second copy. |
| Tables | The query's output shape; bound to a subscription so rows stay live rather than refetched. |
| Filters and sorting | Expressed against the query rather than a client-side copy of the data. |
| Validation | The procedure's input Schema — the same one that rejects a bad request at the boundary. |
| Overrides | Per field: take the binding, render it yourself, keep the rest of the form derived. |
| Admin dashboard | Reads the same schema for browsing, editing and inspection — no hand-built internal CRUD. |
Frequently asked questions
Do I have to use a particular component library?
No. The primitives are unstyled bindings — value, validation state, change handler — so you bring your own components. A shadcn-based kit ships for teams that want a default, but nothing depends on it.
What happens when I need a custom field?
You override that field and keep the rest derived. This is not a generator you eject from: overriding one input does not sever the other twenty from the schema, so adding a column still updates the form.
Does the generated form validate the same way the server does?
Yes, because both read the procedure's input Schema. The client check is for immediate feedback; the server still validates, so a bypassed client is not a bypassed rule.
Can a sensitive column end up in a form?
A server-only column never crosses the wire, so it cannot reach a form at all. The UI inherits the boundary rather than re-implementing it, which is what stops a field from being hidden in the interface but still present in the payload.
Is the admin dashboard usable for customer-facing screens?
The dashboard is one consumer of these bindings; the same bindings serve your product UI. That is deliberate — an admin panel you cannot reuse means writing every screen twice with two sets of validation that drift apart.
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.
Reactive queries
The live subscription behind every DataTable. One hook, push-based, auto-optimistic — the data layer the UI binds to.
End-to-end types
The effect/Schema that types the form is the same one that types the wire and the handler. Change it once; the compiler finds every gap.
AI primitives
AgentChat + AppAgent bind to a synthesized agent; exposeAsTool turns any procedure into a safe LLM tool bounded by the caller's permissions.
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.