Skip to content

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.

ts
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.

tsx
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.

tsx
import Form from "@rjsf/antd";

import { createDynamicFormRuntime } from "./dynamic-form-runtime";

export const AntdDynamicFormRuntime = createDynamicFormRuntime(Form);

To swap UI libraries, change only this file:

tsx
import Form from "@rjsf/shadcn";

import { createDynamicFormRuntime } from "./dynamic-form-runtime";

export const ShadcnDynamicFormRuntime = createDynamicFormRuntime(Form);

Usage ​

tsx
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.

ts
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 uiSchema separate from schema; 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 formVersion with 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.