RJSF Runtime Form
Render versioned JSON Schema forms with react-jsonschema-form, while keeping schema storage and submission handling under your app's control.
What Problem This Solves
You need dynamic forms that:
- Render from stored JSON instead of hand-written React fields
- Preserve the exact schema version used for each submission
- Support different UI libraries through RJSF themes
- Keep API calls, auth, routing, and persistence outside the form renderer
Use this when the hard problem is product workflow around forms, not building a custom form engine.
Prerequisites
@rjsf/core@rjsf/utils@rjsf/validator-ajv8- One RJSF theme package, such as
@rjsf/antd,@rjsf/shadcn, or@rjsf/mui
Files
dynamic-form-types.ts
Keep the versioned form definition separate from the RJSF runtime. This lets your backend store, publish, and migrate forms without knowing about React components.
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
export type PublishedFormDefinition = {
id: string;
slug: string;
title: string;
version: number;
schema: RJSFSchema;
uiSchema?: UiSchema;
publishedAt: string;
};
export type DynamicFormSubmission = {
formId: string;
formVersion: number;
data: Record<string, unknown>;
};dynamic-form-runtime.tsx
The runtime component receives a published form definition and emits a versioned submission. It does not fetch, save, route, or show app-specific notifications.
import type { IChangeEvent } from "@rjsf/core";
import validator from "@rjsf/validator-ajv8";
import type { DynamicFormSubmission, PublishedFormDefinition } from "./dynamic-form-types";
type DynamicFormRuntimeProps = {
definition: PublishedFormDefinition;
disabled?: boolean;
initialData?: Record<string, unknown>;
onSubmit: (submission: DynamicFormSubmission) => void | Promise<void>;
};
export function createDynamicFormRuntime(FormComponent: typeof import("@rjsf/core").default) {
return function DynamicFormRuntime({
definition,
disabled = false,
initialData,
onSubmit,
}: DynamicFormRuntimeProps) {
async function handleSubmit(event: IChangeEvent<Record<string, unknown>>) {
await onSubmit({
formId: definition.id,
formVersion: definition.version,
data: event.formData ?? {},
});
}
return (
<FormComponent
schema={definition.schema}
uiSchema={definition.uiSchema}
validator={validator}
disabled={disabled}
formData={initialData}
omitExtraData
liveOmit="onBlur"
showErrorList={false}
onSubmit={handleSubmit}
/>
);
};
}antd-dynamic-form-runtime.tsx
Create one runtime per theme. The rest of your app imports this local themed runtime, not RJSF directly.
import Form from "@rjsf/antd";
import { createDynamicFormRuntime } from "./dynamic-form-runtime";
export const AntdDynamicFormRuntime = createDynamicFormRuntime(Form);To swap UI libraries, change only this file:
import Form from "@rjsf/shadcn";
import { createDynamicFormRuntime } from "./dynamic-form-runtime";
export const ShadcnDynamicFormRuntime = createDynamicFormRuntime(Form);Usage
import type { PublishedFormDefinition } from "./dynamic-form-types";
import { AntdDynamicFormRuntime } from "./antd-dynamic-form-runtime";
const incidentReportForm: PublishedFormDefinition = {
id: "form_incident_report",
slug: "incident-report",
title: "Incident Report",
version: 3,
publishedAt: "2026-05-18T12:00:00.000Z",
schema: {
type: "object",
required: ["summary", "severity"],
properties: {
summary: {
type: "string",
title: "Summary",
minLength: 10,
},
severity: {
type: "string",
title: "Severity",
enum: ["low", "medium", "high"],
},
needsFollowUp: {
type: "boolean",
title: "Needs follow-up?",
},
},
dependencies: {
needsFollowUp: {
oneOf: [
{
properties: {
needsFollowUp: { const: false },
},
},
{
required: ["followUpOwner"],
properties: {
needsFollowUp: { const: true },
followUpOwner: {
type: "string",
title: "Follow-up owner",
},
},
},
],
},
},
},
uiSchema: {
summary: {
"ui:widget": "textarea",
"ui:options": { rows: 4 },
},
severity: {
"ui:placeholder": "Select severity",
},
},
};
export function IncidentReportPage() {
return (
<AntdDynamicFormRuntime
definition={incidentReportForm}
onSubmit={async (submission) => {
await fetch("/api/form-submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submission),
});
}}
/>
);
}Storage Shape
Store the published form definition separately from submissions. Never resolve old submissions against the latest schema.
type FormDefinitionRecord = {
id: string;
slug: string;
title: string;
status: "draft" | "published" | "archived";
currentVersion: number;
};
type FormVersionRecord = {
id: string;
formId: string;
version: number;
schema: unknown;
uiSchema: unknown;
publishedAt: string;
};
type FormSubmissionRecord = {
id: string;
formId: string;
formVersion: number;
data: unknown;
submittedAt: string;
submittedBy: string;
};Key Points
- Use JSON Schema as the persisted runtime format instead of inventing a new renderer schema.
- Keep
uiSchemaseparate fromschema; validation and display hints change at different rates. - Keep the runtime component pure. Fetching, auth, routing, toasts, and cache invalidation belong in the consuming app.
- Store
formVersionwith every submission so historical data renders against the schema users actually saw. - Prefer RJSF widgets, fields, templates, and themes before writing a custom form engine.
Variations
With a Builder-Friendly DSL
If JSON Schema is too verbose for non-developers, let your builder edit a smaller DSL and compile it to JSON Schema plus uiSchema before publishing. Keep JSON Schema as the runtime boundary.
With Server Validation
Validate submissions again on the server with AJV and the same published schema version. Client-side RJSF validation improves UX, but it is not an authorization or integrity boundary.
With React Hook Form
Do not force React Hook Form into this runtime unless the project requires RHF as the source of truth. If you only need submission data, RJSF's formData and onSubmit are enough.