@mackehansson/schemaform
Guides

Build a Form Builder

A step-by-step guide to building your own form builder admin UI using useFormBuilder.

Build your own form builder admin using the useFormBuilder hook. By the end you will have a working component that lets admins add Text and Select fields, organise them with Pages, Groups, Columns, and Repeaters, define show/hide Rules, and preview the rendered form live.

Each step adds one capability. Read the whole guide before copying code — later steps replace parts of earlier ones.

Prerequisites

Install schemaform:

npm install schemaform

For the live preview in Step 5, use the bundled <SchemaForm />. If you are building your own renderer first, skip that step and come back after completing Build a Form Renderer.

1. Wire up the hook

useFormBuilder is the entire API for building the admin surface. Pass it an initial FormDefinition and it returns the live definition and a set of mutation operations.

import { useState } from "react";
import type { FormDefinition } from "@mackehansson/schemaform";
import { useFormBuilder } from "@mackehansson/schemaform";

const emptyDefinition: FormDefinition = {
  formatVersion: 1,
  schema: { type: "object", properties: {}, required: [] },
  uiSchema: {
    pages: [{ type: "Page", title: "Page 1", children: [] }],
  },
  rules: [],
};

export function MyFormBuilder() {
  const { definition, operations } = useFormBuilder({
    initialDefinition: emptyDefinition,
    palette: ["text", "select"],
  });

  return <div>builder goes here</div>;
}

definition is the live FormDefinition. Every operation produces a new definition — your component just reads it and renders.

operations is a bag of mutation functions. They mutate all three layers (JSON Schema, UI Schema, Rules) together so you never have to manage the layers by hand.

palette is the list of widget names your builder exposes. It does not affect what operations are available — it is a hint to the component about which field types to show in the palette.

Persisting the definition

The hook holds the definition internally. To propagate changes to a parent component (for saving to a database), sync with a useEffect:

export function MyFormBuilder({
  initialDefinition,
  onChange,
}: {
  initialDefinition: FormDefinition;
  onChange: (def: FormDefinition) => void;
}) {
  const { definition, operations } = useFormBuilder({ initialDefinition });

  // Skip the first render — the parent already has the initial value.
  const mounted = useRef(false);
  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    onChange(definition);
  }, [definition]);

  return <div>builder goes here</div>;
}

2. Build the palette

The palette is a list of buttons. Each button calls operations.addField with the widget and JSON Schema for that field type.

import type { FormDefinition, UiNode } from "@mackehansson/schemaform";
import { useFormBuilder } from "@mackehansson/schemaform";

// Generate a unique property key for a new field.
function generateKey(def: FormDefinition, base: string): string {
  const existing = new Set(Object.keys(def.schema.properties ?? {}));
  let key = base;
  let n = 2;
  while (existing.has(key)) key = `${base}${n++}`;
  return key;
}

export function MyFormBuilder() {
  const [pageIndex, setPageIndex] = useState(0);
  const { definition, operations } = useFormBuilder({
    initialDefinition: emptyDefinition,
    palette: ["text", "select"],
  });

  function addTextField() {
    const key = generateKey(definition, "text");
    operations.addField(pageIndex, {
      key,
      label: "Text field",
      widget: "text",
      schema: { type: "string" },
    });
  }

  function addSelectField() {
    const key = generateKey(definition, "select");
    operations.addField(pageIndex, {
      key,
      label: "Select field",
      widget: "select",
      schema: { type: "string", enum: ["Option 1", "Option 2", "Option 3"] },
    });
  }

  return (
    <div style={{ display: "flex", gap: 16 }}>
      <aside
        style={{ width: 160, display: "flex", flexDirection: "column", gap: 8 }}
      >
        <button type="button" onClick={addTextField}>
          + Text field
        </button>
        <button type="button" onClick={addSelectField}>
          + Select field
        </button>
      </aside>

      <main style={{ flex: 1 }}>{/* canvas — next step */}</main>
    </div>
  );
}

addField(pageIndex, spec, placement?) mutates two layers simultaneously:

  • Adds the JSON Schema property to schema.properties
  • Adds a Control node to uiSchema.pages[pageIndex].children

The third argument placement lets you insert the field inside a Group or Columns instead of at the page root — covered in Step 7.

3. Build the canvas

The canvas renders definition.uiSchema.pages[pageIndex].children. For now we only handle Control nodes — layout containers are added in later steps.

import type { ControlNode, UiNode } from "@mackehansson/schemaform";
import type { BuilderOperations } from "@mackehansson/schemaform";

function Canvas({
  nodes,
  selectedScope,
  onSelect,
  onRemoveField,
}: {
  nodes: UiNode[];
  selectedScope: string | null;
  onSelect: (scope: string | null) => void;
  onRemoveField: (scope: string) => void;
}) {
  if (nodes.length === 0) {
    return (
      <p style={{ color: "#888" }}>No fields yet — add one from the palette.</p>
    );
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      {nodes.map((node) => {
        if (node.type !== "Control") return null; // expanded in later steps

        const key = node.scope.replace("#/properties/", "");
        const isSelected = node.scope === selectedScope;

        return (
          <div
            key={node.scope}
            onClick={() => onSelect(isSelected ? null : node.scope)}
            style={{
              border: isSelected ? "2px solid #3b82f6" : "1px solid #d1d5db",
              padding: "8px 12px",
              borderRadius: 6,
              cursor: "pointer",
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              background: "#fff",
            }}
          >
            <div>
              <span style={{ fontWeight: 500 }}>{node.label ?? key}</span>
              <span style={{ marginLeft: 8, fontSize: 12, color: "#6b7280" }}>
                {node.widget ?? "text"}
              </span>
            </div>
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                if (isSelected) onSelect(null);
                onRemoveField(node.scope);
              }}
            >

            </button>
          </div>
        );
      })}
    </div>
  );
}

Wire it into the builder alongside selection state:

export function MyFormBuilder() {
  const [pageIndex, setPageIndex] = useState(0);
  const [selectedScope, setSelectedScope] = useState<string | null>(null);
  const { definition, operations } = useFormBuilder({ ... });

  const currentPage = definition.uiSchema.pages[pageIndex];

  return (
    <div style={{ display: "flex", gap: 16 }}>
      <aside>{/* palette */}</aside>

      <main style={{ flex: 1 }}>
        <Canvas
          nodes={currentPage?.children ?? []}
          selectedScope={selectedScope}
          onSelect={setSelectedScope}
          onRemoveField={(scope) => operations.removeField(scope)}
        />
      </main>
    </div>
  );
}

removeField(scope) removes the JSON Schema property and its Control node from every page simultaneously.

4. Add a settings panel

When a field is selected, render a panel to edit its label, required state, and — for Select — its options.

import type { ControlNode, FormDefinition } from "@mackehansson/schemaform";
import type { BuilderOperations } from "@mackehansson/schemaform";

// Walk the UI Schema to find the ControlNode for a scope.
function findControl(def: FormDefinition, scope: string): ControlNode | null {
  for (const page of def.uiSchema.pages) {
    const found = findControlInNodes(page.children, scope);
    if (found) return found;
  }
  return null;
}

function findControlInNodes(
  nodes: UiNode[],
  scope: string,
): ControlNode | null {
  for (const node of nodes) {
    if (node.type === "Control" && node.scope === scope) return node;
    if (node.type === "Group" || node.type === "Columns") {
      const found = findControlInNodes(node.children, scope);
      if (found) return found;
    }
  }
  return null;
}

function SettingsPanel({
  def,
  scope,
  ops,
  onClose,
}: {
  def: FormDefinition;
  scope: string;
  ops: BuilderOperations;
  onClose: () => void;
}) {
  const key = scope.replace("#/properties/", "");
  const schema = def.schema.properties?.[key] ?? { type: "string" };
  const control = findControl(def, scope);
  const isRequired = def.schema.required?.includes(key) ?? false;
  const isSelect = Array.isArray(schema.enum);

  return (
    <aside
      style={{
        width: 240,
        borderLeft: "1px solid #e5e7eb",
        padding: 16,
        display: "flex",
        flexDirection: "column",
        gap: 16,
      }}
    >
      <div style={{ display: "flex", justifyContent: "space-between" }}>
        <strong>Field settings</strong>
        <button type="button" onClick={onClose}>

        </button>
      </div>

      <label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
        <span style={{ fontSize: 13 }}>Label</span>
        <input
          defaultValue={control?.label ?? ""}
          onBlur={(e) => ops.updateField(scope, { label: e.target.value })}
        />
      </label>

      <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <input
          type="checkbox"
          checked={isRequired}
          onChange={(e) =>
            ops.updateField(scope, { required: e.target.checked })
          }
        />
        <span style={{ fontSize: 13 }}>Required</span>
      </label>

      {isSelect && (
        <OptionsEditor
          options={(schema.enum as string[]) ?? []}
          onUpdate={(next) =>
            ops.updateField(scope, { schema: { ...schema, enum: next } })
          }
        />
      )}
    </aside>
  );
}

function OptionsEditor({
  options,
  onUpdate,
}: {
  options: string[];
  onUpdate: (next: string[]) => void;
}) {
  const [draft, setDraft] = useState("");

  function add() {
    const v = draft.trim();
    if (!v || options.includes(v)) return;
    onUpdate([...options, v]);
    setDraft("");
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <strong style={{ fontSize: 13 }}>Options</strong>
      {options.map((opt, i) => (
        <div key={i} style={{ display: "flex", gap: 4 }}>
          <span style={{ flex: 1, fontSize: 13 }}>{opt}</span>
          <button
            type="button"
            onClick={() => onUpdate(options.filter((_, idx) => idx !== i))}
          >

          </button>
        </div>
      ))}
      <div style={{ display: "flex", gap: 4 }}>
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && add()}
          placeholder="New option…"
          style={{ flex: 1 }}
        />
        <button type="button" onClick={add}>
          Add
        </button>
      </div>
    </div>
  );
}

updateField(scope, updates) accepts partial updates. Passing { label } only touches the Control node. Passing { required: true } only touches schema.required. Passing { schema } replaces the JSON Schema property. You never manage the three layers separately.

Add SettingsPanel to the builder layout:

<div style={{ display: "flex", gap: 16 }}>
  <aside>{/* palette */}</aside>
  <main style={{ flex: 1 }}>{/* canvas */}</main>
  {selectedScope && (
    <SettingsPanel
      def={definition}
      scope={selectedScope}
      ops={operations}
      onClose={() => setSelectedScope(null)}
    />
  )}
</div>

5. Live preview

Pass definition to <SchemaForm> in a panel. Every operation on the builder is reflected in the preview immediately because definition is the shared source of truth.

import { SchemaForm } from "@mackehansson/schemaform-shadcn";

// Inside the builder layout:
<div style={{ flex: 1, borderLeft: "1px solid #e5e7eb", padding: 16 }}>
  <p style={{ fontSize: 12, color: "#6b7280", marginBottom: 12 }}>Preview</p>
  <SchemaForm
    definition={definition}
    onSubmit={(data) => console.log("Submitted:", data)}
  />
</div>;

After completing Build a Form Renderer you can replace <SchemaForm> with your own renderer. The contract is the same: receive definition, call onSubmit with the working state.

6. Add pages

Multi-page forms need page tabs and three operations: addPage, removePage, and renamePage.

function PageTabs({
  pages,
  activeIndex,
  onSwitch,
  onAdd,
  onRemove,
  onRename,
}: {
  pages: { title?: string }[];
  activeIndex: number;
  onSwitch: (i: number) => void;
  onAdd: () => void;
  onRemove: (i: number) => void;
  onRename: (i: number, title: string) => void;
}) {
  return (
    <div
      style={{
        display: "flex",
        gap: 4,
        alignItems: "center",
        marginBottom: 12,
      }}
    >
      {pages.map((page, i) => (
        <div key={i} style={{ display: "flex", alignItems: "center" }}>
          <button
            type="button"
            onClick={() => onSwitch(i)}
            onDoubleClick={() => {
              const title = prompt(
                "Page title:",
                page.title ?? `Page ${i + 1}`,
              );
              if (title?.trim()) onRename(i, title.trim());
            }}
            style={{ fontWeight: activeIndex === i ? "bold" : "normal" }}
          >
            {page.title ?? `Page ${i + 1}`}
          </button>
          {pages.length > 1 && (
            <button type="button" onClick={() => onRemove(i)}>

            </button>
          )}
        </div>
      ))}
      <button type="button" onClick={onAdd}>
        + Page
      </button>
    </div>
  );
}

Wire it into the builder. Keep pageIndex clamped when a page is removed:

<PageTabs
  pages={definition.uiSchema.pages}
  activeIndex={pageIndex}
  onSwitch={setPageIndex}
  onAdd={() => {
    const idx = definition.uiSchema.pages.length;
    operations.addPage(`Page ${idx + 1}`);
    setPageIndex(idx);
  }}
  onRemove={(i) => {
    operations.removePage(i);
    setPageIndex((prev) =>
      Math.min(prev, definition.uiSchema.pages.length - 2),
    );
  }}
  onRename={(i, title) => operations.renamePage(i, title)}
/>

removePage removes the page and all its Control nodes and the corresponding JSON Schema properties. Fields on other pages are untouched.

7. Groups and Columns

Groups and Columns are layout containers. Add palette items and pass the layout type to addLayout.

// Add to the palette:
function addGroup() {
  const len = currentPage?.children.length ?? 0;
  operations.addLayout(pageIndex, [], len, "Group");
}

function addColumns() {
  const len = currentPage?.children.length ?? 0;
  operations.addLayout(pageIndex, [], len, "Columns");
}

addLayout(pageIndex, parentPath, insertIndex, type, spec?) inserts a new Group or Columns node. parentPath: [] means the top level of the current page. Pass a spec with title to give a Group a visible heading.

The canvas now needs to render containers recursively. Replace the flat Canvas with a recursive CanvasNode:

function CanvasNodeList({
  nodes,
  parentPath,
  pageIndex,
  ops,
  selectedScope,
  onSelect,
}: {
  nodes: UiNode[];
  parentPath: number[];
  pageIndex: number;
  ops: BuilderOperations;
  selectedScope: string | null;
  onSelect: (scope: string | null) => void;
}) {
  if (nodes.length === 0) {
    return <p style={{ color: "#888", fontSize: 13 }}>Drop fields here</p>;
  }
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      {nodes.map((node, i) => (
        <CanvasNode
          key={i}
          node={node}
          path={[...parentPath, i]}
          pageIndex={pageIndex}
          ops={ops}
          selectedScope={selectedScope}
          onSelect={onSelect}
        />
      ))}
    </div>
  );
}

function CanvasNode({
  node,
  path,
  pageIndex,
  ops,
  selectedScope,
  onSelect,
}: {
  node: UiNode;
  path: number[];
  pageIndex: number;
  ops: BuilderOperations;
  selectedScope: string | null;
  onSelect: (scope: string | null) => void;
}) {
  if (node.type === "Control") {
    const key = node.scope.replace("#/properties/", "");
    const isSelected = node.scope === selectedScope;
    return (
      <div
        onClick={() => onSelect(isSelected ? null : node.scope)}
        style={{
          border: isSelected ? "2px solid #3b82f6" : "1px solid #d1d5db",
          padding: "8px 12px",
          borderRadius: 6,
          cursor: "pointer",
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          background: "#fff",
        }}
      >
        <div>
          <span style={{ fontWeight: 500 }}>{node.label ?? key}</span>
          <span style={{ marginLeft: 8, fontSize: 12, color: "#6b7280" }}>
            {node.widget ?? "text"}
          </span>
        </div>
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            if (isSelected) onSelect(null);
            ops.removeField(node.scope);
          }}
        >

        </button>
      </div>
    );
  }

  if (node.type === "Group" || node.type === "Columns") {
    const label =
      node.type === "Group"
        ? `Group${node.title ? `: ${node.title}` : ""}`
        : "Columns";
    const isColumns = node.type === "Columns";
    return (
      <div
        style={{
          border: "2px dashed #d1d5db",
          borderRadius: 6,
          padding: 8,
        }}
      >
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            marginBottom: 8,
          }}
        >
          <span style={{ fontSize: 12, fontWeight: 600, color: "#6b7280" }}>
            {label}
          </span>
          <button
            type="button"
            onClick={() => ops.removeLayout(pageIndex, path)}
          >

          </button>
        </div>
        <div style={isColumns ? { display: "flex", gap: 8 } : {}}>
          <CanvasNodeList
            nodes={node.children}
            parentPath={path}
            pageIndex={pageIndex}
            ops={ops}
            selectedScope={selectedScope}
            onSelect={onSelect}
          />
        </div>
      </div>
    );
  }

  return null; // Array — next step
}

To add a field inside a container, pass the container's path as parentPath to addField:

// If a Group at path [1] is selected, fields go inside it:
operations.addField(
  pageIndex,
  { key, label: "Text field", widget: "text", schema: { type: "string" } },
  { parentPath: selectedContainerPath, insertIndex: containerChildCount },
);

For a simple click-to-add builder, track which container is selected separately from which field is selected, then use the container path when calling addField.

8. Repeaters

A Repeater renders a dynamic list of rows. Each row follows a rowTemplate — a list of nodes describing one row's layout. Row template fields live inside schema.properties[key].items.properties in the JSON Schema, so use addRepeaterField (not addField) to add them.

Add a palette button:

function addRepeater() {
  const key = generateKey(definition, "list");
  operations.addRepeater(pageIndex, { key, label: "List" });
}

Add fields to a repeater's row template with addRepeaterField:

function addTextFieldToRepeater(repeaterScope: string) {
  const repeaterKey = repeaterScope.replace("#/properties/", "");
  const key = generateRepeaterKey(definition, repeaterKey, "text");
  operations.addRepeaterField(repeaterScope, {
    key,
    label: "Text field",
    widget: "text",
    schema: { type: "string" },
  });
}

// Generate a unique key within the repeater's items schema.
function generateRepeaterKey(
  def: FormDefinition,
  repeaterKey: string,
  base: string,
): string {
  const items = def.schema.properties?.[repeaterKey]?.items as
    | { properties?: Record<string, unknown> }
    | undefined;
  const existing = new Set(Object.keys(items?.properties ?? {}));
  let key = base;
  let n = 2;
  while (existing.has(key)) key = `${base}${n++}`;
  return key;
}

Add the Array case to CanvasNode:

if (node.type === "Array") {
  const key = node.scope.replace("#/properties/", "");
  return (
    <div
      style={{
        border: "2px dashed #f59e0b",
        borderRadius: 6,
        padding: 8,
      }}
    >
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          marginBottom: 8,
        }}
      >
        <span style={{ fontSize: 12, fontWeight: 600, color: "#92400e" }}>
          Repeater: {node.label ?? key}
        </span>
        <button type="button" onClick={() => ops.removeRepeater(node.scope)}>

        </button>
      </div>
      <p style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>
        Row template
      </p>
      <CanvasNodeList
        nodes={node.rowTemplate}
        parentPath={[]}
        pageIndex={pageIndex}
        ops={ops}
        selectedScope={selectedScope}
        onSelect={onSelect}
      />
      <button
        type="button"
        style={{ marginTop: 8, fontSize: 12 }}
        onClick={() => addTextFieldToRepeater(node.scope)}
      >
        + Text field in row
      </button>
    </div>
  );
}

removeRepeaterField(repeaterScope, fieldKey) removes the field from the row template and from items.properties. removeRepeater(scope) removes the entire array including its schema property.

9. Add Rules

Rules link a source field's current value to a show/hide effect on a target field. The rule editor below handles the most common case: "when field A equals a value, show/hide field B."

import type { Rule } from "@mackehansson/schemaform";

function RuleEditor({
  def,
  ops,
}: {
  def: FormDefinition;
  ops: BuilderOperations;
}) {
  const [source, setSource] = useState("");
  const [value, setValue] = useState("");
  const [effect, setEffect] = useState<"show" | "hide">("show");
  const [target, setTarget] = useState("");

  const fieldKeys = Object.keys(def.schema.properties ?? {});

  function addRule() {
    if (!source || !value || !target) return;
    ops.addRule({
      condition: { "==": [{ var: source }, value] },
      effect,
      target: `#/properties/${target}`,
    });
    setValue("");
  }

  return (
    <div style={{ marginTop: 24 }}>
      <strong>Rules</strong>

      {def.rules.length === 0 && (
        <p style={{ fontSize: 13, color: "#6b7280" }}>No rules yet.</p>
      )}

      {def.rules.map((rule, i) => (
        <div
          key={i}
          style={{
            display: "flex",
            gap: 8,
            alignItems: "center",
            fontSize: 13,
            marginTop: 8,
          }}
        >
          <span style={{ flex: 1 }}>
            {rule.effect} <code>{String(rule.target)}</code> when{" "}
            <code>{JSON.stringify(rule.condition)}</code>
          </span>
          <button type="button" onClick={() => ops.removeRule(i)}>

          </button>
        </div>
      ))}

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr auto 1fr auto 1fr auto",
          gap: 8,
          alignItems: "flex-end",
          marginTop: 16,
        }}
      >
        <label
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 4,
            fontSize: 13,
          }}
        >
          Source field
          <select value={source} onChange={(e) => setSource(e.target.value)}>
            <option value="">—</option>
            {fieldKeys.map((k) => (
              <option key={k} value={k}>
                {k}
              </option>
            ))}
          </select>
        </label>

        <label
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 4,
            fontSize: 13,
          }}
        >
          equals
          <input
            value={value}
            onChange={(e) => setValue(e.target.value)}
            placeholder="value"
            style={{ width: 80 }}
          />
        </label>

        <label
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 4,
            fontSize: 13,
          }}
        >
          Effect
          <select
            value={effect}
            onChange={(e) => setEffect(e.target.value as "show" | "hide")}
          >
            <option value="show">show</option>
            <option value="hide">hide</option>
          </select>
        </label>

        <label
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 4,
            fontSize: 13,
          }}
        >
          Target field
          <select value={target} onChange={(e) => setTarget(e.target.value)}>
            <option value="">—</option>
            {fieldKeys.map((k) => (
              <option key={k} value={k}>
                {k}
              </option>
            ))}
          </select>
        </label>

        <button
          type="button"
          onClick={addRule}
          style={{ alignSelf: "flex-end" }}
        >
          Add rule
        </button>
      </div>
    </div>
  );
}

A Rule has three fields:

  • condition — a JSONLogic expression. { "==": [{ var: "fieldKey" }, "value"] } reads fieldKey from the Working State and checks equality. The renderer evaluates this on every keystroke.
  • effect — what happens when the condition is true. "show" makes the target visible while true and hidden otherwise. "hide" is the inverse.
  • target — a Control scope like #/properties/city, or a layout node id to show/hide an entire Group or Page.

removeRule(index) removes the rule at that position in definition.rules.

To extend this to Group-level targets, populate a second dropdown with the id values from your Group nodes. The same addRule call works — just pass the Group id as target instead of a scope.

What's next

You now have a working form builder with the full API surface covered. To take it further:

  • Reordering — wire up drag-and-drop with Add Drag and Drop, or use operations.moveNode and operations.moveRepeaterNode directly for up/down buttons
  • Undo / redouseFormBuilder already tracks history; wire up undo, redo, canUndo, and canRedo from the hook return value
  • More rule effects — extend RuleEditor with "enable", "disable", "require", and "unrequire"; they use the same addRule call with a different effect
  • More widgets — add palette buttons for textarea, number, date, and any custom widgets in your registry
  • Repeater layout — use addRepeaterLayout to put Groups and Columns inside a Repeater's row template

Next: Build a Form Renderer — wire up useFormRenderer to render the forms your builder produces.

On this page