@mackehansson/schemaform

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 areaWhat it changes
FieldsJSON Schema properties and UI Schema Controls
LayoutUI Schema Groups and Columns
Repeatersarray schemas, Array nodes, and row templates
PagesUI Schema Pages and page ordering
Rulespersisted Rules targeting Controls, layout nodes, or Pages
Root settingsnavigation 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

Form Builder
Namename
Text
Emailemail
Text
Messagemessage
Textarea

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": []
}

On this page