Schema-Driven UI: From Hard-Coded Screens to Configurable Platforms
How I render forms, tables, and pages from JSON schema—component maps, dynamic loaders, and where permissions plug in.
Hard-coded screens ship fast once. The tenth variant of “almost the same form” is where products stall. On admin and operations products I move repeatable UI to schema-driven rendering—engineers own the map and guardrails; product owns JSON (or a visual editor) for fields, layout, and actions.
Where configuration wins
| Surface | Config might describe |
|---|---|
| Form | Fields, validation, visibility rules, defaults |
| Table | Columns, formatters, row actions |
| Page | Sections, tabs, nested blocks |
| Actions | Button type, dialog, API binding |
| Campaigns | Time windows, rewards, rule expressions |
Goal: launch a new operational flow without a frontend release when risk allows.
Core idea
Express the screen as data:
{
"type": "form",
"fields": [
{
"type": "input",
"label": "Username",
"field": "username",
"required": true
},
{
"type": "select",
"label": "Role",
"field": "role",
"options": [
{ "label": "Admin", "value": "admin" },
{ "label": "User", "value": "user" }
]
}
]
}
Mount one renderer:
<DynamicForm config={formConfig} onSubmit={handleSubmit} />
The engine maps type → React component, wires validation and events, and stays ignorant of business nouns except through schema.
Renderer architecture
interface ConfigSchema {
type: "form" | "table" | "page";
fields?: FieldSchema[];
columns?: ColumnSchema[];
layout?: LayoutBlock[];
}
const registry: Record<string, React.ComponentType<FieldProps>> = {
input: TextField,
select: SelectField,
date: DateField,
// ...
};
function renderField(field: FieldSchema) {
const Component = registry[field.type];
if (!Component) throw new Error(`Unknown field type: ${field.type}`);
return <Component key={field.field} {...field} />;
}
Patterns I always add:
- Version on schema for migrations (
schemaVersion: 2) - Strict allowlist of
typevalues—unknown types fail in CI, not in prod - Async options via
optionsUrl+ caching, not giant static JSON - Expression layer for show/hide (small DSL or JSON Logic), not
eval
Dynamic loading (when bundles grow)
const loaders = {
richText: () => import("./fields/RichTextField"),
chart: () => import("./fields/ChartField"),
};
const Component = lazy(loaders[field.type]);
Keeps initial admin bundle smaller; heavy widgets load only when schema requests them.
Permissions and config
Bind action visibility to the same codes as RBAC:
{
"type": "button",
"label": "Export",
"action": "exportUsers",
"permission": "btn.user.export"
}
The renderer skips nodes the user cannot run—server still enforces.
Pitfalls
| Pitfall | Mitigation |
|---|---|
| Schema becomes a second programming language | Limit expressiveness; code escape hatches for rare cases |
| No validation of JSON | JSON Schema + CI fixtures per screen |
| Performance on huge tables | Virtualization, server pagination |
| Designer/PM edits break prod | Staging preview + audit log |
Relation to AI-assisted delivery
On teams using SDD + Rules + Skills, schema files are excellent spec artifacts: the agent implements or extends DynamicForm, humans edit JSON for the rollout. That is how I keep velocity without surrendering type safety on the engine itself.
Configurable UI is the bridge between “React contractor” and “platform engineer”—relevant for Upwork clients who outgrow one-off landing pages and need operable internal tools.