Extension Points
Where to plug in custom widgets, validators, renderers, persistence, submission behavior, and builder UI.
Most Schemaform customization falls into one of two buckets:
- change the Form Definition when you are modeling data, presentation, or conditional behavior
- change app code when you need components, side effects, storage, network calls, or product-specific behavior
The Form Definition should stay declarative. Your custom code plugs in around it.
Where do I put custom code?
| I want to... | Extension point | Why |
|---|---|---|
| Render a field with a custom component | Registry widget | Same data shape, different presentation |
| Override the built-in input styling | Registry widget override | Renderer owns concrete components |
| Add server-side validation | custom validator | Validation needs app code or network access |
| Translate labels and errors | translation hook/function | Text resolution belongs at the renderer boundary |
| Use MUI, Chakra, Radix, or my design system | custom Renderer | Component stack is renderer-owned |
| Save forms to my database | app persistence | Schemaform does not own storage |
| Submit data to my API | onSubmit handler | Schemaform produces the payload; your app sends it |
| Add a new admin workflow | custom builder UI or builder hook | Product workflow sits above Core mutations |
| Add conditional visibility or required state | Rules | Behavior belongs in the persisted definition |
| Add a field type to the builder palette | Registry entry | The palette is derived from widget names |
| Support a JSON Schema feature outside the renderable subset | Core/renderability extension | The engine must learn how to resolve it |
| Add a new rule effect | Core Rules Engine plus Renderer support | Effects must resolve and render consistently |
Custom field UI
Use a custom widget when the submitted data shape stays the same but the field should render differently.
import {
FieldShell,
SchemaForm,
defaultRegistry,
useRegisteredField,
type WidgetProps,
type WidgetRegistry,
} from "@mackehansson/schemaform";
function ColorSwatchWidget(props: WidgetProps) {
const { invalid, inputProps } = useRegisteredField(props);
return (
<FieldShell {...props} htmlFor={props.control.path}>
<input
type="color"
{...inputProps}
className={invalid ? "border-destructive" : "border-input"}
/>
</FieldShell>
);
}
const registry = {
...defaultRegistry,
"color-swatch": ColorSwatchWidget,
} satisfies WidgetRegistry;
<SchemaForm definition={definition} registry={registry} onSubmit={console.log} />;Then point a Control at the widget:
{
type: "Control",
scope: "#/properties/accentColor",
widget: "color-swatch",
}Use Widgets & Registry for the full widget contract.
Custom validation
Use JSON Schema for constraints that are local and declarative: required fields, formats, lengths, ranges, enums, and object structure.
Use custom validators when validation needs app code:
- checking whether a username is available
- asking an API whether an account number is valid
- enforcing tenant-specific business rules
- comparing against data that is not inside the form
<SchemaForm
definition={definition}
customValidators={{
"#/properties/username": async (value) => {
if (typeof value !== "string") return null;
const available = await isUsernameAvailable(value);
return available ? null : "Username is already taken";
},
}}
onSubmit={saveProfile}
/>;Custom validators run at the hook/renderer boundary. They should return messages, not mutate the Form Definition.
Read Custom Validators for pending states, async behavior, and page navigation.
Translation
Use translation when text changes by locale, tenant, or product context, but the form structure stays the same.
<SchemaForm
definition={definition}
translate={(key, fallback) => t(key, { defaultValue: fallback })}
onSubmit={onSubmit}
/>;Translation belongs in renderer code because it depends on runtime application context. The Form Definition can hold stable labels, message keys, or fallback text; your app decides how those become localized strings.
Read Internationalization for the translate contract.
Persistence
Schemaform does not choose where Form Definitions live. Store them wherever your product stores configuration:
- database rows
- versioned JSON files
- CMS entries
- tenant settings
- imported/exported documents
async function saveDefinition(formId: string, definition: FormDefinition) {
await api.forms.update(formId, { definition });
}
async function loadDefinition(formId: string) {
const record = await api.forms.get(formId);
return record.definition;
}Persist the whole envelope:
{
formatVersion: 1,
schema,
uiSchema,
rules,
}Do not store renderer-only runtime state inside the definition. Working State, touched fields, pending validators, current page, and submit status belong in the runtime UI.
Read Persistence & Versioning for the envelope and versioning model.
Submit behavior
Use onSubmit for product behavior after Schemaform has produced a Submission Payload.
<SchemaForm
definition={definition}
onSubmit={async (payload) => {
await api.applications.create(payload);
navigate("/thanks");
}}
/>;The payload is not the same as raw Working State. Hidden fields are stripped unless retained, and array/control visibility has already been interpreted by the resolved view model.
Keep workflow behavior outside the Form Definition:
- API calls
- redirects
- toast messages
- analytics
- email triggers
- domain object creation
That keeps definitions portable and keeps executable behavior in app-owned code.
Custom renderer
Build a custom Renderer when changing individual widgets is not enough.
Good reasons:
- your app uses a different component library
- your form library is not React Hook Form
- layout must match a product-specific shell
- accessibility, focus, or error UX needs deep control
- you are rendering outside React DOM
A Renderer consumes useFormRenderer, walks the resolved view model, and maps resolved Controls to widgets.
definition + data
-> useFormRenderer
-> currentPage.children
-> render nodes
-> registry[control.widget]Read Renderer Authoring before building one. The main rule is to render from the resolved model, not directly from raw JSON Schema.
Custom builder UI
Use the ShadCN <FormBuilder /> when its admin workflow fits. Build your own builder UI when your product needs a different authoring experience:
- a wizard for non-technical users
- locked templates with only a few editable fields
- approval workflows
- product-specific field catalogs
- custom import or migration screens
The builder UI should call the builder hook and Core mutation functions rather than hand-editing scattered pieces of the definition.
Your builder screen
-> useFormBuilder
-> add field / move node / update schema / update rule
-> updated Form Definition
-> saveRead Form Builder for the admin surface and builder hook.
Conditional behavior
Use Rules when behavior should be stored with the form and replayed anywhere the form renders.
{
condition: { "==": [{ var: "applicantType" }, "Company"] },
effect: "show",
target: "#/properties/companyName",
}Use app code when behavior depends on external side effects or product workflow:
| Behavior | Use |
|---|---|
show this field when another value is "Company" | Rules |
| require a page only in one branch | Rules |
| redirect after submit | app code |
| call an API when a field changes | widget or renderer code |
| change available options from server data | widget or renderer code |
Read Rules Engine for available effects and condition modeling.
Extending Core
Extend Core only when the form engine needs a new shared capability. This is a deeper change than adding a widget.
Examples:
- a new UI Schema node type
- a new Rule effect
- a new renderable JSON Schema construct
- a new submission projection rule
- a new definition migration
When extending Core, update the full path:
- domain types
- resolver or Rules Engine behavior
- renderability checks if schema support changes
- React hook surface if renderers need new state
- ShadCN renderer/builder support
- docs and tests
This keeps Form Builder output and Form Renderer behavior aligned.
Choosing the right extension point
Ask this first: does the change describe the form, or does it execute product code?
| If the change describes... | Put it in... |
|---|---|
| valid submitted data | JSON Schema |
| visual arrangement | UI Schema |
| conditional form behavior | Rules |
| concrete field rendering | Registry/widget |
| runtime validation with side effects | custom validators |
| storage, APIs, auth, redirects, analytics | your app |
| a different component/form stack | custom Renderer |
| a different admin authoring workflow | custom builder UI |
That boundary is the main architectural rule: definitions are portable descriptions; extension points are where application code enters the system.