Skip to main content
JSON-driven forms in React are forms where the structure — fields, validation rules, conditional logic, and layout — is defined in a JSON object rather than hardcoded in JSX components. The form renders from that schema at runtime. Change the schema, and the form changes, with no code deployment required. This guide covers what JSON forms are, when they’re the right architecture choice, the main libraries available in 2026, and how to implement the most common patterns with FormEngine.
FormEngine — JSON-driven form builder with visual designer
Last updated: April 2026

What are JSON-driven forms?

A traditional React form is written as JSX: you compose <input>, <label>, and validation logic directly in your component. If the form needs to change, you change the code and redeploy. A JSON-driven form separates the form definition from the rendering engine. The schema is a data object — it can live in a file, a database, or an API response. A renderer reads the schema and produces the form. The result looks identical to the user; the difference is architectural.
{
  "form": {
    "key": "Screen",
    "type": "Screen",
    "children": [
      {
        "key": "email",
        "type": "MuiTextField",
        "props": { "label": { "value": "Email address" } },
        "schema": { "validations": [{ "key": "email" }] }
      }
    ]
  }
}
This JSON fully describes a form with one email field and email validation. Pass it to a renderer like FormViewer and you get a working, validated React form.

Why use JSON for forms instead of JSX?

1. Forms that change without a code deployment
If your forms live in a database and are served via API, you can update them without touching the codebase. This is essential for admin panels, CMS-driven forms, A/B testing different form configurations, or any scenario where business requirements change faster than deployment cycles.
2. Single source of truth across systems
The same JSON schema can be stored once, rendered in a React app, validated server-side, exported as a PDF form, or sent to an external service. With JSX forms, every environment needs its own implementation.
3. Visual authoring for non-developers
JSON schemas can be generated by visual form builders (drag-and-drop editors) and consumed by runtime renderers. This lets product managers, support teams, and operations staff create and modify forms without a developer involved.
4. Reuse and versioning
Form schemas are data — you can store them in a database with versioning, copy them between environments, diff them in version control, and roll them back like any other data artifact.
5. Multi-tenant form management
SaaS products that need different forms per customer can store per-tenant schemas and serve them dynamically. No code branch for each customer’s requirements.

When JSON forms are NOT the right choice

It’s worth being honest about this because many comparisons skip it. JSON-driven forms add complexity. If your forms are stable, code-owned, and unlikely to change independently of your codebase, the overhead of a JSON schema layer is not worth it. Specifically:
  • Simple static forms — login, registration, contact — that will never change don’t benefit from JSON-driven architecture. React Hook Form or plain HTML will be simpler.
  • Extremely custom UI requirements — if every field is a bespoke interactive component, the renderer will need heavy customization. Code-first is cleaner.
  • Very small bundles required — JSON form renderers are larger than lightweight code-first libraries. If bundle size is a critical constraint, TanStack Form (~12 KB) or React Hook Form (~26 KB) are better choices.
  • Tight TypeScript end-to-end — if you need full type inference from schema to field value, code-first approaches like TanStack Form have an architectural advantage.

JSON form libraries in React: 2026 comparison

LibraryLicenseJSON Schema formatVisual builderActive developmentMUI supportBundle (gzip)
FormEngine CoreMITFormEngine schemaFormEngine DesignerYesNative adapter~245 KB / ~190 KB w/ MUI
RJSF (react-jsonschema-form)Apache 2.0JSON Schema draft-07NoYesTheme package~176 KB default
UniformsMITZod, JSON Schema, SimpleSchemaNoYesTheme package~15–20 KB core + theme
FormilyMITJSON Schema draft-07Formily DesignableYesVia @formily/antd~50 KB core
SurveyJSCommercialSurveyJS schemaSurveyJS CreatorYesTheme package~427 KB default
Which library for which use case:
  • FormEngine — best when you need runtime rendering + an embeddable visual designer in one stack, and you’re using MUI or Mantine
  • RJSF — best when you already have JSON Schema definitions from your backend and need a straightforward renderer; limited customization
  • Uniforms — best when you want to auto-generate forms from existing Zod/JSON Schema data models; no visual authoring
  • Formily — best for complex enterprise forms with reactive cross-field dependencies; steeper learning curve, strong Ant Design ecosystem
  • SurveyJS — best for surveys, questionnaires, and research forms; commercial licensing required for the visual creator

FormEngine architecture in depth

FormEngine Core separates the form definition from the renderer with a pluggable component layer:
JSON Schema

FormViewer (runtime engine)

View Adapter (component mapping)

React Components (MUI / Mantine / custom)

Rendered Form
The view prop is a component adapter that maps string type names (e.g. "MuiTextField") to actual React components. Pre-built adapters exist for Material UI and Mantine. You can build adapters for any component library. The engine handles:
  • Form state management
  • Field-level and form-level validation (Zod-backed)
  • Conditional rendering (renderWhen expressions)
  • Computed properties (computeType: "function")
  • Event handling and action sequences
  • Localization

The FormEngine JSON schema format

A FormEngine schema is a JSON object with the following top-level structure:
{
  "version": "1",
  "errorType": "MuiErrorWrapper",
  "tooltipType": "MuiTooltip",
  "defaultLanguage": "en-US",
  "languages": [...],
  "localization": {...},
  "form": {
    "key": "Screen",
    "type": "Screen",
    "children": [...]
  }
}
Each component in the tree has:
{
  "key": "fieldKey",       // unique identifier and form data key
  "type": "ComponentType", // maps to a component in the view adapter
  "props": {               // component properties (label, placeholder, etc.)
    "label": { "value": "My field" }
  },
  "schema": {              // validation rules
    "validations": [{ "key": "required" }]
  },
  "renderWhen": {          // conditional visibility
    "value": "form.data.someField === 'yes'"
  },
  "events": {              // event → action bindings
    "onClick": [
      { "name": "validate", "type": "common" }
    ]
  },
  "children": [...]        // nested components
}

Real-world patterns

Conditional fields

Show or hide fields based on other field values using renderWhen:
{
  "key": "companyName",
  "type": "MuiTextField",
  "props": { "label": { "value": "Company" } },
  "renderWhen": {
    "value": "form.data.accountType === 'business'"
  }
}
The expression is evaluated against form.data (the current form values) on every change. When it returns falsy, the field is hidden and its value is excluded from form data. See Conditional Logic for the full reference.

Computed field values

Derive a field’s display value from other fields using computeType: "function":
{
  "key": "summary",
  "type": "MuiTypography",
  "props": {
    "children": {
      "computeType": "function",
      "fnSource": "return `Hello, ${form.data.firstName} ${form.data.lastName}`"
    }
  }
}
See Computed Properties.

Nested and repeatable fields

Use container components (MuiStack, MuiBox) for layout, and the Repeater component for dynamic field arrays:
{
  "key": "addresses",
  "type": "Repeater",
  "children": [
    { "key": "street", "type": "MuiTextField", "props": { "label": { "value": "Street" } } },
    { "key": "city", "type": "MuiTextField", "props": { "label": { "value": "City" } } }
  ]
}

Form submission with validation

Attach an action sequence to a button’s onClick event — validate first, then run your custom handler:
{
  "key": "submit",
  "type": "MuiButton",
  "props": { "children": { "value": "Submit" } },
  "events": {
    "onClick": [
      { "name": "validate", "type": "common", "args": { "failOnError": true } },
      { "name": "onSubmit", "type": "custom" }
    ]
  }
}
The validate action is built-in. onSubmit maps to a custom handler passed via the actions prop to FormViewer:
<FormViewer
  view={muiView}
  getForm={getForm}
  actions={{
    onSubmit: async (event) => {
      await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(event.data)
      })
    }
  }}
/>

Loading schema from an API

const [schema, setSchema] = useState<string | null>(null)

useEffect(() => {
  fetch(`/api/forms/${formId}`)
    .then(res => res.json())
    .then(data => setSchema(JSON.stringify(data)))
}, [formId])

const getForm = useCallback(() => schema!, [schema])

if (!schema) return <Spinner />

return <FormViewer view={muiView} getForm={getForm} />

Validation with JSON-driven forms

FormEngine’s validation system uses Zod under the hood. Validation rules live in the schema alongside the field definition:
{
  "key": "age",
  "type": "MuiTextField",
  "schema": {
    "validations": [
      { "key": "required" },
      { "key": "min", "args": { "limit": 18, "message": "Must be at least 18" } },
      { "key": "max", "args": { "limit": 120 } }
    ]
  }
}
Validation runs automatically on field blur and on form submit. You can also trigger it imperatively via viewerRef.current.formData.getValidationResult(). See Validation.

Performance considerations

JSON-driven forms do have a performance cost compared to statically compiled JSX forms:
  • Parse overhead: The schema is parsed at runtime. For large schemas (hundreds of fields), consider caching the parsed schema in a useMemo and passing the stringified form via getForm.
  • Bundle size: FormEngine Core adds ~245 KB gzip (or ~190 KB with MUI adapter, replacing the default components). This is larger than a code-first library. See Bundle Size Comparison for measured data.
  • Re-renders: FormEngine manages its own internal state. Use useMemo and useCallback to avoid re-creating the getForm function and actions object on every render — this prevents unnecessary re-initialization of the form.
// Good: stable references
const getForm = useCallback(() => JSON.stringify(formSchema), [])
const actions = useMemo(() => ({ onSubmit: handleSubmit }), [handleSubmit])

// Bad: new function on every render, causes form re-initialization
return <FormViewer getForm={() => JSON.stringify(formSchema)} actions={{ onSubmit: handleSubmit }} />

Migration from code-first to JSON-first

Moving from React Hook Form or Formik to a JSON-driven approach doesn’t require a full rewrite. The practical path is incremental:
  1. Start with new forms: write any new forms as FormEngine JSON schemas
  2. Identify high-change forms: forms that product teams request changes to frequently are the best migration candidates
  3. Migrate validation rules: map your current Yup/Zod rules to FormEngine’s validation schema — the concepts are similar
  4. Port event handlers: move onSubmit handlers to FormEngine actions — the function signatures are different but the logic is identical
  5. Enable visual authoring optionally: once a form is in JSON, product managers can use FormEngine Designer to make changes without developer involvement

Next steps

Last modified on April 15, 2026