Schema to Form Fields
How a backend JSON Schema becomes real form controls - the support matrix, field types, fieldHint, reference pickers and validation.
This page is the frontend side of the conversion - what Thunder UI does with the JSON Schema it receives. To author that schema, see Controlling the UI From the Backend and the Schema Metadata Reference.
src/core/crud/metadata.ts exports fieldsFromModuleMetadata(metadata, opts). It picks the schema that matches the intent and converts it with JSONSchemaToFields.toFields:
| Intent | Schema resolution |
|---|---|
insert | crud.insertSchema ?? crud.schema |
update | crud.updateSchema ?? crud.insertSchema ?? crud.schema |
output | crud.schema |
The function is total - it always returns TField[] and never rejects. A module that exposes create/update without a schema yields an empty array, and the form shows an empty state.
The worked schema on the Schema Metadata page exercises most of this in one place.
JSON Schema Support Matrix
| Construct | Result |
|---|---|
object with properties | nested field group, arbitrarily deep |
array of objects | repeatable group (orders.0.lines.0.sku) |
array of scalars | tag input (multi) |
array of arrays of scalars | per-row tag input |
required | propagates, including inside array items |
enum | dropdown (strings or numbers) |
format: date, date-time, email, uri, e164 | date / date / email / url / phone |
$ref / $defs | resolved, including recursive refs |
allOf | merged into the host schema |
oneOf / anyOf | branches merged into one object; a const discriminator becomes an enum |
type: ["string","null"] | resolves to the non-null type |
default | becomes the field's initial value |
const | submitted, rendered hidden |
additionalProperties | JSON textarea |
prefixItems | sibling fields name.0, name.1 |
Default rule: a property is optional unless its parent lists it in required.
Unsupported constructs degrade to a text input and emit a one-time console.warn. Set JSONSchemaToFields.silent = true to suppress those warnings.
Field Type to Control
| Field | Control |
|---|---|
text, maxLength absent or above 100 | textarea |
text, maxLength 100 or below | single-line input |
number | number input |
boolean | switch |
date | date input |
email / url | input with the matching type |
phone | international phone input (lazy-loaded) |
hidden | not rendered; still registered if it has const |
enum present | dropdown, or multiselect when multi |
ref present | searchable, server-paginated picker |
array of objects | repeatable group with add/remove |
object | nested field group |
any multi scalar | tag input |
fieldHint
A schema property may carry fieldHint to pick a specific control instead of the default for its type. On the backend you set it with .meta({ fieldHint }):
fieldHint | Applies to | Control |
|---|---|---|
avatar | url, single | avatar uploader |
upload | url | file-table uploader (fileType, fileSize, maxItems honoured) |
markdown | text | markdown editor (lazy-loaded) |
json | text | JSON textarea; blocks invalid JSON |
autocomplete | enum | type-ahead instead of a dropdown |
amount | number | amount-styled number input |
datetime-local | date | date + time input |
filters | object | MongoDB filter builder; requires filterSchema |
The avatar and upload hints wire straight into ImageKit.
Reference Fields
A field with ref renders a searchable, server-paginated picker that queries the referenced module on demand - 25 per page, debounced search, and every request abortable. This is what Thunder's foreign-reference fields produce.
| Key | Meaning |
|---|---|
ref | module name this field references |
refLabel | property or properties to display (string or string[]) |
refValue | property to submit (default _id) |
refFilters | filters applied when querying the referenced module |
TField
The shape the renderers consume. Any extra key on a JSON Schema property is carried through onto the field - that is how the hints above work, and how a .meta() key you invent yourself reaches your own override component.
| Key | Meaning |
|---|---|
type | a field type from the table above, or "array" / "object" |
name | dotted path used as the form field name |
label, description | passed through t() |
placeholder, example | placeholder text |
optional | true unless the parent requires it |
defaultValue | from the schema's default |
multi | scalar array, rendered as a tag input |
enum | string[] or an array of { label, value } |
minLength, maxLength, minimum, maximum, minItems, maxItems, pattern | validation |
fieldHint | see above |
const | single legal value; submitted, not editable |
group | consecutive fields sharing a group render in one FieldGroup |
groupClassName, groupStyle, className, style | styling passthrough |
requirementKey | render only when the watched field's value equals this field's name |
fileType, fileSize | upload constraints |
queryValue, ignoreQueryValue | prefill from a URL query parameter, or opt out |
fields | child fields for array and object |
className and groupClassName can arrive from a backend schema at runtime, where Tailwind's scanner cannot see them. src/index.css ships an @source inline(...) block that force-generates the grid and gap utilities for exactly this reason - don't remove it.
Validation
buildRules(field, t) derives the react-hook-form rules from the schema:
requiredpatternminLength/maxLengthfor textmin/maxfor numbersminItems/maxItemsfor arrays
All messages go through t(), so they are translatable.
Server-side messages surface automatically: an axios interceptor registered in initThunder() raises one toast per entry in error.response.data.messages. Aborted requests never toast.