Dein Schema hat das Backend bereits typisiert.
Jetzt baut es die UI.

Jede Voltro-Procedure trägt ein effect/Schema — Input, Output und die Tabelle, in die sie schreibt. Schema-driven UI projiziert das ins Frontend: Ein Formular ist ein Binding an eine Mutation, eine Tabelle ein Binding an eine Query. Du bekommst Felder, Spalten, Validierung und live auto-optimistische Updates, ohne den Glue-Code zu schreiben. Und weil das Binding ein reaktives, auto-optimistisches Backend ist — kein totes REST-Schema — ist die generierte Tabelle eine Live-Subscription, kein Grid, das du neu lädst.

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>}
/>

Der Descriptor ist die einzige Quelle der Wahrheit.

Du hast das input-Schema, das output-Schema und die Ziel-Tabelle bereits einmal deklariert, auf dem Server. Schema-driven UI liest diese — es verlangt nie, dass du deine Daten in einer Formular-Config neu beschreibst. Annotation passt an, wie ein Feld rendert; sie verdrahtet nie neu, woran das Feld gebunden ist.

Formulare binden an eine Mutation

AutoForm liest das input-Schema der Mutation und rendert die Felder: eine Literal-Union → ein Select, ein String → Text, ein Boolean → eine Checkbox. Validierung, Submit und op-korrektes Auto-Optimistic gibt es gratis. Client und Server teilen exakt dasselbe Schema — und auf einer server-gerenderten Seite funktioniert das Formular mit deaktiviertem JavaScript: ein nativer POST erreicht dieselbe Mutation durch dieselbe Validierung, Erfolg redirectet (PRG), Fehler rendern die Seite mit denselben Feldmeldungen erneut.

Tabellen binden an eine Query

DataTable liest das output-Schema der Query für die Spalten und subscribed für die Zeilen. Die Liste aktualisiert sich bei jedem Write — kein Refetch, kein Cache zu invalidieren. pageSize aktiviert ein Live-"Load more", das das Fenster wachsen lässt und weiter streamt.

Picker binden an eine Query

Eine reference()-Spalte wird standardmäßig zu einem debounced, live Typeahead auf der konventionellen Such-Query. AsyncSelect (oder das headless useQueryField) gibt dir Create-on-the-fly-Comboboxen ohne Konfiguration — die Optionsliste ist selbst reaktiv.

Eine Anpassungs-Leiter, keine Klippe

Feld annotieren → ein Widget per Render-Prop → eine Widget-Art app-weit tauschen → das ganze Layout selbst besitzen → auf den headless Hook ejecten. Auf KEINER Sprosse verlierst du das Binding. Genau hier stirbt jeder andere Schema→UI-Generator; hier ist es das Design.

Reaktive Komponenten, nicht nur CRUD

Drop-in-UI für das durable + reaktive Backend: WorkflowProgress für eine Live-Schritt-Timeline, PresenceAvatars / EditingIndicator für Multiplayer, AgentChat für einen reconnect-sicheren AI-Chat. Jede hat einen headless Hook darunter.

Ein Werkzeugkasten gebundener Hooks

useCan (gaten auf denselben Scopes, die der Server durchsetzt), usePreview (eine Mutation in einer zurückgerollten Transaktion als Dry-Run ausführen), useUndo (universelles Ctrl-Z über Mutationen), useAsyncValidation, RecordView, ein typisierter Analytics-Katalog, eine Offline-Outbox, gefensterte Subscriptions. Alles Projektionen desselben Descriptor-Graphen.

Zucker obendrauf, headless darunter.

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.

Die Spalte entscheidet, nicht die Ansicht.

database/schema.ts
ts
// 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())

Keiner der beiden Komponenten wurde gesagt, welche Felder es gibt. Sie lesen die Deklaration — eine neue Spalte ergibt also ein Eingabefeld und eine Tabellenspalte, und eine als serverseitig markierte Spalte erreicht den Browser gar nicht erst, um versehentlich gerendert zu werden.

Schema-getriebene UI im Detail.

Was erzeugt das Schema eigentlich?

Formular-Bindings, Tabellenspalten, Filter, Validierungsmeldungen und die zugehörigen Feldtypen — abgeleitet aus derselben Deklaration, die Datenbank und api ohnehin nutzen. Eine Text-Spalte wird ein Text-Input, ein Enum ein Select, eine nullable Spalte ein optionales Feld, und jedes trägt die Validierung, die der Server sowieso anwendet.

Der Wert liegt nicht in gesparten Tastenanschlägen. Er liegt darin, dass das Formular dem Server nicht widersprechen kann, weil beide eine Deklaration lesen. Eine neue Pflichtspalte macht das Formular verpflichtend, ohne dass jemand daran denkt; eine entfernte verschwindet aus der Tabelle, statt eine Spalte voller undefined zu hinterlassen.

Es ist Ableitung, keine Generierung in Dateien. Es gibt keine generierte Komponente zum Neuerzeugen und Diffen und keinen Ejection-Schritt, an dem die Verbindung zum Schema beim ersten Sonderwunsch durchtrennt wird.

Was passiert, wenn die generierte Komponente nicht passt?

Du überschreibst das Feld und behältst alles andere. Das Binding gibt dir Wert, Validierungszustand und Change-Handler; wie es rendert, ist deine Sache. Ein Datumsfeld, das einen eigenen Picker braucht, zwingt nicht das restliche Formular zurück in Handarbeit.

Das ist der Unterschied zu einem Scaffolding-Generator, der dir einmal Code gibt und danach nichts mehr zu bieten hat — sobald du ihn bearbeitest, folgt er dem Schema nicht mehr, und die Drift beginnt am selben Tag.

Deshalb sind die Primitive auch ungestylte Bindings statt einer Komponentenbibliothek, die du komplett übernehmen musst. Bring deine eigenen Komponenten mit; das Framework liefert die Verdrahtung zwischen einem Schema-Feld und dem, was es rendert.

Wie greift das mit Live-Daten zusammen?

Eine an eine Query gebundene Tabelle ist eine Subscription — sie aktualisiert, wenn sich die zugrunde liegenden Zeilen ändern, auch durch einen anderen Nutzer oder Dienst, statt nach einem von dir geplanten Refetch. Sortierung, Filterung und Paginierung werden gegen die Query ausgedrückt statt gegen eine Client-Kopie.

An ein Formular gebundene Mutationen bekommen dasselbe auto-optimistische Verhalten wie jede andere Mutation: die Liste patcht vor dem Ende des Roundtrips und gleicht ab, wenn der Server antwortet — ohne handgeschriebenen optimistischen Updater pro Formular.

Für lange Listen gibt es eine gefensterte Subscription, sodass nur ein Ausschnitt im Speicher liegt. Das zählt, wenn die Tabelle das Produkt ist und nicht ein Einstellungs-Screen.

Ist das ein Admin-Panel?

Es ist die Schicht, aus der ein Admin-Panel gebaut wird — und eines ist dabei: ein Dashboard, das dein Schema liest und dir Durchsehen, Bearbeiten und Inspizieren gibt, ohne dass du CRUD-Screens für den internen Gebrauch baust.

Aber dieselben Bindings bedienen kundenseitige UI, und das ist der Punkt. Ein generiertes Admin-Panel, das man fürs echte Produkt nicht nutzen kann, heißt alles zweimal schreiben: einmal für Mitarbeiter, einmal für Nutzer, mit zwei Validierungssätzen, die auseinanderlaufen.

Die Zugriffsentscheidungen gelten in beiden Fällen. Ein Feld, das das Subject nicht lesen darf, wird nicht gerendert, weil es die Leitung nie überquert, und eine server-only-Spalte erreicht ein Formular gar nicht — die UI erbt die Grenze, statt sie nachzubauen.

Was das Schema treibt.

Was die Schema-Deklaration in der UI treibt
OberflächeAbgeleitet aus
FormularfelderSpaltentypen und Nullability — mit derselben Validierung, die der Server anwendet, keine zweite Kopie.
TabellenDer Ausgabeform der Query; an eine Subscription gebunden, Zeilen bleiben also live statt neu geladen.
Filter und SortierungGegen die Query ausgedrückt statt gegen eine Client-seitige Kopie der Daten.
ValidierungDem Input-Schema der Procedure — demselben, das einen schlechten Request an der Grenze ablehnt.
OverridesPro Feld: nimm das Binding, render selbst, lass den Rest des Formulars abgeleitet.
Admin-DashboardLiest dasselbe Schema für Durchsehen, Bearbeiten und Inspizieren — kein handgebautes internes CRUD.

Häufige Fragen

Muss ich eine bestimmte Komponentenbibliothek nutzen?

Nein. Die Primitive sind ungestylte Bindings — Wert, Validierungszustand, Change-Handler — du bringst also deine eigenen Komponenten mit. Ein shadcn-basiertes Kit liegt für Teams bei, die einen Default wollen, aber nichts hängt davon ab.

Was passiert, wenn ich ein eigenes Feld brauche?

Du überschreibst dieses Feld und lässt den Rest abgeleitet. Das ist kein Generator, aus dem man aussteigt: ein überschriebenes Input trennt die anderen zwanzig nicht vom Schema, eine neue Spalte aktualisiert das Formular also weiterhin.

Validiert das generierte Formular so wie der Server?

Ja, weil beide das Input-Schema der Procedure lesen. Die Client-Prüfung ist für sofortiges Feedback; der Server validiert trotzdem — ein umgangener Client ist also keine umgangene Regel.

Kann eine sensible Spalte in einem Formular landen?

Eine server-only-Spalte überquert die Leitung nie, sie kann ein Formular also gar nicht erreichen. Die UI erbt die Grenze, statt sie nachzubauen — genau das verhindert ein Feld, das im Interface versteckt, aber in der Payload vorhanden ist.

Ist das Admin-Dashboard für kundenseitige Screens nutzbar?

Das Dashboard ist ein Konsument dieser Bindings; dieselben Bindings bedienen deine Produkt-UI. Das ist Absicht — ein Admin-Panel, das man nicht wiederverwenden kann, heißt jeden Screen zweimal zu schreiben, mit zwei Validierungssätzen, die auseinanderdriften.

Warum die generierte Variante die handgeschriebene tatsächlich schlägt.

Auto-generiertes CRUD scheitert meist, weil es gegen ein totes REST/SQL-Schema generiert und gegen eine Wand läuft, sobald du irgendetwas Eigenes brauchst. Voltros Variante ist auf einer Achse anders: Die generierte UI bindet an eine reaktive, auto-optimistische Runtime. Die Generierung ist die Auslieferung; die Reaktivität ist der Wert.

Eine Änderung, zwei Oberflächen:

Füge der Tabelle eine Spalte hinzu → das Entity-Schema ändert sich → das input-Schema der Mutation ändert sich → AutoForm lässt das Feld wachsen UND DataTable lässt die Spalte wachsen, beide typisiert, beide validiert, beim nächsten Reload. Keine Formular-Library zu aktualisieren, keine Spalten-Config zu synchronisieren, kein optimistischer Reducer von Hand. Ejecte auf den headless Hook an dem Tag, an dem du den Defaults entwächst — das Binding (Schema + Submit + Live-Updates) überlebt den Eject.

Es komponiert mit dem Rest der Runtime.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.