Forms, switch product
announcements

GolemUI v1.6.0: forms from a JSON Schema

GolemUI v1.6.0 is out. It adds fromJsonSchema, a function that turns a JSON Schema into a GolemUI form definition, plus a CLI command and MCP tools around it. It also comes with a new logo and a mascot, Roger.

If your form data already has a JSON Schema, for example an OpenAPI request body or the output of Zod’s z.toJSONSchema(), you no longer have to describe the same data a second time as a form. fromJsonSchema writes the form definition from the schema. @golemui/schemas exports it, and @golemui/gui-schemas exports the gui preset that chooses the widgets:

Terminal window
npm i @golemui/schemas @golemui/gui-schemas
import { fromJsonSchema } from '@golemui/schemas/json-schema';
import { guiPreset } from '@golemui/gui-schemas/json-schema';
const schema = await fetch('/api/order.schema.json').then((response) =>
response.json(),
);
const { formDefinition, diagnostics } = fromJsonSchema(schema, {
preset: guiPreset(),
});
const config = { formDef: formDefinition };

The result is a plain form definition. Pass config to the form component of your framework. The function imports no Node modules, so it also runs in the browser and in Workers.

The gui preset maps each schema node to a widget:

  • An enum, or a oneOf or anyOf of const values, becomes a select, or a dropdown above 6 options.
  • A string becomes a dateInput, dateTimePicker or timeInput by format, a password for format: password or a name such as apiKey, a markdown editor for contentMediaType: text/markdown, a textarea from a maxLength of 200, and a textinput otherwise.
  • A number becomes a number, and a boolean a checkbox.
  • An array of objects becomes a repeater, an array of choices a multiList or multiDropdown, and an array of other values tags.
  • An object becomes a flex column.

title becomes the label, description the hint, the first entry of examples the placeholder, and default the default value. Constraints such as required, pattern, minLength, minimum and format become gui validators.

The converter reads draft-04, draft-07 and 2020-12 schemas, and OpenAPI 3.0 schema objects. It resolves $ref into $defs, definitions and OpenAPI components/schemas, merges allOf and unwraps nullable types. A recursive $ref stops after two expansions by default.

The converter handles the parts of a schema where one value depends on another. Take this if/then/else from an order schema:

"if": { "properties": { "country": { "const": "US" } }, "required": ["country"] },
"then": {
"properties": {
"zip": { "pattern": "^[0-9]{5}$" },
"state": { "type": "string", "title": "State", "enum": ["CA", "NY", "TX"] }
},
"required": ["zip", "state"]
},
"else": { "properties": { "zip": { "pattern": "^[0-9]{4} ?[A-Z]{2}$" } } }

zip is declared on the object, so it stays visible and only its constraints change with the country. Each branch condition becomes a form state:

"states": {
"if01": "$form.country === \"US\"",
"if02": "!($form.country === \"US\")"
}

The zip field gets one validator per state:

{
"kind": "input",
"type": "textinput",
"path": "zip",
"label": "Postal code",
"validator.if01": {
"type": "string",
"required": true,
"pattern": "^[0-9]{5}$"
},
"validator.if02": { "type": "string", "pattern": "^[0-9]{4} ?[A-Z]{2}$" },
"props": { "hint": "The format depends on the country." }
}

state exists only in the then branch. It becomes a select with "include": { "when": "$form.country === \"US\"" }, so the form shows it only for US orders. dependentRequired, dependentSchemas and draft-07 dependencies compile the same way. In the order schema, "dependentRequired": { "company": ["vatNumber"] } makes the VAT number required when the company name is set.

A oneOf or anyOf of objects becomes a selector plus the properties of each branch. The selector is the OpenAPI discriminator.propertyName, or else the property that has a different const in every branch. In the order schema, the payment is a card or a bank transfer. The converter makes payment.method a select with the options “Card” and “Bank transfer”, and it gives the card number field "include": { "when": "$form.payment?.method === \"card\"" }. The form removes the data of hidden widgets on submit, so the payload holds only the branch the user chose.

The demo below is the form fromJsonSchema writes for the full order schema, with no edits by hand. Choose a country, switch the payment method, or type a company name, and the form shows the fields and checks the constraints that the schema defines for that case.

The preset chooses a widget for every node, and four layers can change that choice. For each node, the first layer that names a widget builds it: a path override, a $defs override, the x-golemui keyword in the schema, and then the rules, yours before the preset’s.

const { formDefinition } = fromJsonSchema(schema, {
preset: guiPreset(),
rules: [
{ match: { $type: 'string', format: 'email' }, props: { icon: 'mail' } },
],
overrides: {
plan: { widget: 'radiogroup' },
newsletter: { widget: 'toggle' },
},
});

A rule without widget only adds its fields to the widget that the next rule builds, so the rule above puts a mail icon on every e-mail field. This rule is plain JSON, so the same rule works in a config file or in an MCP call. When matching on keywords is not enough, a rule can be a pair of functions instead, { when(node), build(node, context) }.

If you own the schema, you can store the choice in the schema itself:

"bio": {
"type": "string",
"title": "Bio",
"x-golemui": { "widget": "markdown", "props": { "autoGrow": true } }
}

Every layer can set label, props, validator, defaultValue, readonly and size as well. skip: true leaves a node out, and order sets the order of the properties of an object.

To commit a generated form to your repository, run the convert command. --pointer converts one schema of a larger document, such as an OpenAPI file, and resolves its $refs against the whole document:

Terminal window
npx @golemui/schemas convert openapi.json --pointer /components/schemas/Customer \
--preset @golemui/gui-schemas/json-schema --out src/forms/customer.form.json

A generated form is often edited by hand, so the command replaces an existing --out file only with --force. The same settings, including rules and overrides, can be set in a json-schema.config.mjs file.

An unsupported schema does not make the conversion throw. Anything the form cannot express exactly is listed in diagnostics, each entry with a severity, a code, the form data path and a JSON pointer into the input. The CLI writes them to standard error:

warning no-discriminator at contact (/properties/contact): No property tells the `anyOf` branches apart (a `const` in every branch, or `discriminator.propertyName`). Every branch property is rendered, optional and always visible.

With --fail-on warning the command exits with 1 on a line like this one, so a CI step fails when a schema change adds something the form cannot express. The Forms from JSON Schema page has the full reference for the options, the rule syntax, the supported keywords and every diagnostic code.

  • The MCP server uses the converter. json_generate_from_schema and json_generate_from_openapi now call fromJsonSchema, so they handle $ref, unions and conditions the same way. Both tools accept rules and overrides, and both return diagnostics.
  • validateFormDefinition runs in Cloudflare Workers. @golemui/gui-mcp/json compiled its JSON Schemas with Ajv at runtime, which Workers do not allow, so every call threw an EvalError. The validators are now compiled at build time, and the error messages stay the same.
  • A repeater can be bound to a property named items. Template paths use items as the row token, for example lines.items.quantity, so the core could not distinguish a property named items from the token. It now places each row by position, and a path such as invoice.items.items.name works at any nesting level.

There are no code changes for existing forms. Bump every @golemui/* package to 1.6.0 together, as always. To use the converter, add @golemui/schemas and @golemui/gui-schemas to your dependencies.

The full list of changes is in the changelog.

GolemUI has a new logo, and it comes with Roger, our mascot! Roger is a golem that lives inside our engine. We hope you like it!

Look closely at Roger’s face: it’s a password field, and the eyes are the dots. You’d think that makes Roger good at keeping secrets, but its feelings are hard to hide:

idle
happy
love
wink
surprise
thinking
sleep
valid
invalid
loading

Roger lives inside a form engine, so of course it has opinions about forms. Say hi below and watch it react as you type. Try submitting without a name too: Roger takes validation very seriously.

mood: idle

You’ll also find Roger keeping an eye on our contact form, and you’ll see a lot more of it soon, here and on X, LinkedIn and YouTube. Follow along, Roger would love that!

Previous GolemUI v1.5.1: strict CSP and file upload fixes Next GolemUI v2.0.0: Introducing GolemUI Components