Form Builder
Build an admin design surface with useFormBuilder or the drop-in FormBuilder.
The Form Builder is the admin-facing surface where your users design forms by editing a Form Definition. It is headless-first: useFormBuilder is the primary API, and the drop-in <FormBuilder /> is the first-party ShadCN implementation built on top of that hook.
Persistence is outside Schemaform. Store the Form Definition in your own database, file store, CMS, or product configuration system, then pass it back to the Form Builder when the admin edits it again.
Headless API
Use useFormBuilder when you are building your own admin UI. The hook wraps Core mutation functions with undo/redo history and returns the current Form Definition, mutation operations, history controls, and the widget palette you provide.
import { useFormBuilder } from "@mackehansson/schemaform";
import { defaultRegistry } from "@mackehansson/schemaform-shadcn";
function CustomBuilder({ initialDefinition }: { initialDefinition: FormDefinition }) {
const builder = useFormBuilder({
initialDefinition,
palette: Object.keys(defaultRegistry),
});
return (
<button
type="button"
onClick={() =>
builder.operations.addField(0, {
key: "phone",
label: "Phone",
schema: { type: "string" },
widget: "text",
})
}
>
Add phone field
</button>
);
}The operations mutate the three persisted layers together:
| Operation area | What it changes |
|---|---|
| Fields | JSON Schema properties and UI Schema Controls |
| Layout | UI Schema Groups and Columns |
| Repeaters | array schemas, Array nodes, and row templates |
| Pages | UI Schema Pages and page ordering |
| Rules | persisted Rules targeting Controls, layout nodes, or Pages |
| Root settings | navigation mode and error reveal timing |
Each mutation pushes a new history entry. undo, redo, canUndo, and canRedo are part of the hook return value, so custom builders can expose history however their product expects.
Drop-in builder
Use the ShadCN <FormBuilder /> when you want a complete admin design surface now. It is controlled by value and onChange:
import { useState } from "react";
import type { FormDefinition } from "@mackehansson/schemaform";
import { FormBuilder } from "@mackehansson/schemaform-shadcn";
export function BuilderAdmin({
savedDefinition,
saveDefinition,
}: {
savedDefinition: FormDefinition;
saveDefinition: (definition: FormDefinition) => void;
}) {
const [definition, setDefinition] = useState(savedDefinition);
return (
<>
<FormBuilder value={definition} onChange={setDefinition} />
<button type="button" onClick={() => saveDefinition(definition)}>
Save
</button>
</>
);
}value is the current Form Definition. onChange receives the next Form Definition after edits. Schemaform does not save automatically; your app decides when to persist the value.
Registry and palette
The builder palette is fed by the Renderer Registry. In the drop-in builder, Object.keys(registry) becomes the field palette. If you pass a custom registry, its widget names become the field types admins can add.
<FormBuilder
value={definition}
onChange={setDefinition}
registry={myRegistry}
/>A Widget is presentation. Choosing textarea, select, or date sets the Control's widget name and uses the matching JSON Schema defaults for new fields. Validation constraints still belong in JSON Schema.
Canvas, pages, and settings
The drop-in builder gives admins a design canvas backed by the same headless operations:
- The palette adds fields, Groups, Columns, and Repeaters.
- Canvas drag-and-drop places and reorders Controls and layout nodes.
- Page tabs add, remove, rename, and reorder Pages.
- Field settings edit labels, widget overrides, required state, and supported JSON Schema constraints.
- The Rules and Settings views edit behavior and form-level navigation UX.
- Preview mode renders the current Form Definition with the Form Renderer.
Those interactions all round-trip through the controlled value/onChange contract, so your app can inspect, diff, save, publish, or discard the edited definition.
Live example
Try adding a field from the palette, renaming a Page tab, changing a field label, using Undo, or opening Preview. The JSON panel updates as the Form Builder emits new Form Definitions.
Live builder
Source
import { useState } from "react";import type { FormDefinition } from "@mackehansson/schemaform";import { FormBuilder } from "@mackehansson/schemaform-shadcn";const initialDefinition = { formatVersion: 1, schema: { type: "object", required: ["name", "email"], properties: { name: { type: "string", title: "Name", minLength: 2 }, email: { type: "string", title: "Email", format: "email" }, message: { type: "string", title: "Message" }, }, }, uiSchema: { pages: [ { type: "Page", title: "Contact", children: [ { type: "Control", scope: "#/properties/name" }, { type: "Control", scope: "#/properties/email" }, { type: "Control", scope: "#/properties/message", widget: "textarea", }, ], }, ], }, rules: [],} satisfies FormDefinition;export function BuilderAdmin() { const [definition, setDefinition] = useState<FormDefinition>(initialDefinition); return ( <FormBuilder value={definition} onChange={setDefinition} /> );}Current Form Definition
{
"formatVersion": 1,
"schema": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string",
"title": "Name",
"minLength": 2
},
"email": {
"type": "string",
"title": "Email",
"format": "email"
},
"message": {
"type": "string",
"title": "Message"
}
}
},
"uiSchema": {
"pages": [
{
"type": "Page",
"title": "Contact",
"children": [
{
"type": "Control",
"scope": "#/properties/name"
},
{
"type": "Control",
"scope": "#/properties/email"
},
{
"type": "Control",
"scope": "#/properties/message",
"widget": "textarea"
}
]
}
]
},
"rules": []
}