Skip to content
Forms, switch product

GolemUI in five claims

Form libraries make you choose: state management hooks where you write every input yourself, or schema-driven renderers locked to a single framework. GolemUI takes neither trade. One form definition, written with the TS API or the JSON API, produces the same form, with the same widgets and the same behavior, in React, Angular, Vue, Lit, and Vanilla JS.

Forms are plain function calls returning plain data. That means everything you already do with code works on your forms: extract constants, spread shared props, map over your domain model, build fields with your own helper functions.

const required = { validator: { type: 'string', required: true } } as const;
const address = (prefix: string) => [
gui.inputs.dropdown(`${prefix}.country`, {
label: 'Country',
items: [
{ label: 'Spain', value: 'es' },
{ label: 'France', value: 'fr' },
],
...required,
}),
gui.inputs.textInput(`${prefix}.city`, { label: 'City', ...required }),
gui.inputs.textInput(`${prefix}.street`, { label: 'Street', ...required }),
gui.inputs.textInput(`${prefix}.zip`, { label: 'ZIP code', ...required }),
];
export const checkoutForm = [
...address('shipping'),
...address('billing'),
gui.actions.button({ label: 'Place order', actionType: 'submit' }),
];

No JSX trees to copy between frameworks, no template syntax to port. The definition is the single source of truth to share it, diff it, or generate it.

When a definition has to live outside your code, you write it with the JSON API, validated by published JSON Schemas: autocompletion and inline errors in your editor, no plugins. And the JSON isn’t static: named states, conditional visibility, and per-state properties are part of the format.

{
"$schema": "https://golemui.com/schemas/form.schema.json",
"states": {
"newsletter": "$form.subscribe === true"
},
"form": [
{
"kind": "input",
"type": "textinput",
"path": "email",
"label": "Get notified",
"label.newsletter": "Get notified and subscribe to our newsletter",
"validator": { "type": "string", "required": true, "format": "email" },
"props": {
"hint": "We'll send you an email when we launch the product"
}
},
{
"kind": "input",
"type": "checkbox",
"path": "subscribe",
"label": "Subscribe to our newsletter"
},
{
"kind": "input",
"type": "radiogroup",
"path": "frequency",
"label": "Newsletter frequency",
"props": {
"options": [
{ "label": "Weekly", "value": "weekly" },
{ "label": "Bi-weekly", "value": "bi-weekly" },
{ "label": "Monthly", "value": "monthly" }
]
},
"include": { "in": ["newsletter"] }
}
]
}

Tick the checkbox and the form reacts: the newsletter state activates, the frequency field appears, and the email label updates itself. Suffix any property with a state name and it switches automatically. Validation lives in the definition too, so it travels with it.

Store form definitions in a database, serve them from an API, let a backend or an agent edit them. They’re data.

The widgets are the same under every framework, so the output is identical by construction, not by discipline. We don’t promise parity, we test it: the same shared test suite runs against React, Angular, Vue, and Lit on every change.

Moving a form from your React app to your Angular admin means changing the mount point, not rewriting the form. And it composes across frameworks: the address() helper from above can live in a shared package and serve two different products.

// React app: user profile
export const editUserForm = [
gui.inputs.textInput('user.name', { label: 'Name' }),
...address('user.billing'),
];
// Angular app: parcel shipping, same import
export const shippingForm = [
...address('shipment.destination'),
gui.inputs.datePicker('shipment.pickupDate', { label: 'Pickup date' }),
];

Same fields, same validation, same look, two frameworks. Fix the street validator once and both apps get it. Want countries populated from your API instead of a static list? Change the shared helper, in one place:

gui.inputs.dropdown(`${prefix}.country`, {
label: 'Country',
onLoad: (event) => {
fetch('/api/countries')
.then((r) => r.json())
.then((items) => event.update({ path: `${prefix}.country`, items }));
},
...required,
}),

Every form using address(), in every app, in every framework, now loads countries from the server. There is no per-framework data-fetching code to keep in sync.

GolemUI ships an MCP server and agent skills that let coding agents build forms with either API and check their own work. Agent-generated JSON is validated against the real schemas; agent-generated TypeScript is type-checked against the library’s actual type declarations. The MCP refuses to approve code it can’t verify, so agents can’t hallucinate an API that doesn’t exist.

Say an agent invents a plural validators array that doesn’t exist:

gui.inputs.textInput('email', {
label: 'Email',
validators: [{ required: true, format: 'email' }],
});

dx_check_code rejects it with the real compiler diagnostic:

{
"ok": false,
"diagnostics": [
{
"code": 2769,
"message": "... 'validators' does not exist in type 'Partial<Omit<TextDataInputDecorator, \"type\">>'. Did you mean to write 'validator'?",
"line": 2
}
]
}

The agent fixes it and re-checks. No hallucinated API ever reaches your codebase.

It also generates. Point it at a JSON Schema or an OpenAPI operation and get a working form back, with a report of anything it couldn’t map. Given the request body of a typical shipping endpoint, POST /shipments/{id}/method:

type: object
required: [method]
properties:
method:
type: string
title: Shipping method
enum: [standard, express, priority]
signatureRequired:
type: boolean
title: Signature on delivery
instructions:
type: string
title: Delivery instructions

json_generate_from_openapi returns a validated form definition. The enum becomes a select with its values enforced by the validator:

{
"kind": "input",
"type": "select",
"path": "method",
"label": "Shipping method",
"validator": {
"type": "string",
"required": true,
"enum": ["standard", "express", "priority"]
},
"props": {
"options": [
{ "label": "Standard", "value": "standard" },
{ "label": "Express", "value": "express" },
{ "label": "Priority", "value": "priority" }
]
}
}

The boolean becomes a checkbox, the optional string a plain text input, and a submit button is added at the end. The response closes with "diagnostics": [] and a validation report. An empty diagnostics list tells the agent, or your CI, that the form expresses the whole schema.

GolemUI means freedom to choose. We know that every project is a world of its own, and what fits perfectly in one can be the wrong call in the next. We ship defaults so you can start in minutes: a complete widget set, Zod-backed validators, packaged themes. But every one of them is a default, not a cage:

  • UI kit. Swap any widget for your own component, or bring an entire widget set from your design system.
  • Validation. Validators speak Standard Schema, so you can plug in your own Zod, Valibot, or ArkType schemas.
  • i18n. Bring your own library, i18next or anything else, with hot language switching and automatic RTL either way.
  • Styling. Use a packaged theme, restyle through CSS custom properties on light-DOM widgets your stylesheet can always reach, or skip our CSS entirely and go headless. Widgets stay functional and accessible no matter what you do with the styles.
  • Dependencies. Widgets that need a third-party helper, like a markdown parser, don’t bundle one: you inject the implementation you prefer.
  • Authoring. The dx pipeline that powers gui.* is public: use it to build your own typed generator functions and selector chains for your own widget set.

Open source, for us, means trust: anyone can read the code, audit it for security, report issues, and help shape what comes next.

GolemUI will always be open source. Forever.

Everything above is one install away. If you would rather start with the story, Why GolemUI on the blog tells it through a project you may have lived through.

Get started GitHub