April 1, 2026
Server-Driven Forms: Stop Duplicating Validation Logic
When your frontend replicates backend business rules for form validation, you have a maintenance problem. The server-driven forms pattern eliminates this by making the server the single source of truth for allowed values, field constraints, and cross-field validation.
Sascha Becker
Author20 min read
Server-Driven Forms: Stop Duplicating Validation Logic
There's a problem I suspect most frontend developers have encountered. You have a REST endpoint (or a GraphQL mutation) that accepts a complex object. The object has conditional constraints: if one attribute changes, another must be different. Some fields only allow certain values depending on the current state of other fields. The API returns a malformed HTTP error when the object is wrong.
The frontend team's instinct is to replicate all of this logic in React. Required fields, conditional requirements, dynamic dropdown options, cross-field rules. The result was hundreds of lines of validation code that mirrored the backend, drifted over time, and broke in ways that only showed up when the server rejected what the client had approved.
This is a well-known architectural smell. The industry has a name for the solution: server-driven forms.
This article walks through the pattern using a concrete example: a zoo animal transfer form. Not because you're building zoo software, but because it's the kind of form where the complexity is immediately obvious and client-side validation falls apart fast.
The example: zoo animal transfer
Imagine you're building the internal tool for a zoo network that manages animal transfers between facilities. A zookeeper needs to fill out a transfer request. The form looks simple at first glance: pick an animal, pick a destination, choose a transport method, set a date.
But the data comes from everywhere:
- The species registry determines CITES protection status and legal requirements
- The facilities database knows which enclosures are available and what habitats they support
- The veterinary system tracks health certificates and their expiry dates
- The regulatory API provides import/export rules per country
- The transport system knows container specs and carrier availability per species
And the rules cascade. Changing the species changes which destinations have suitable enclosures. Changing the destination country changes which health certificates are required and how recent they must be. Selecting air freight for a primate triggers a container ventilation requirement that doesn't apply to reptiles. A CITES Appendix I species requires ministerial approval, which adds fields that don't exist for Appendix II animals.
Try duplicating all of that in React.
Why client-side validation breaks down
If you try to encode these rules on the client, you end up with something like this:
tsxfunction TransferForm({ transfer }: { transfer: Transfer }) {const [values, setValues] = useState(transfer);// duplicated from backendconst isCitesI = getCitesStatus(values.speciesId) === "APPENDIX_I";const needsMinisterialApproval = isCitesI;const allowedDestinations = getDestinationsWithEnclosure(values.speciesId);const requiredCerts = getRequiredCerts(values.speciesId, values.destinationId);const allowedTransport = getTransportMethods(values.speciesId, values.weight);return (<form><Select name="species" options={allSpecies} /><Select name="destination" options={allowedDestinations} /><Select name="transport" options={allowedTransport} />{needsMinisterialApproval && <TextField name="approvalReference" />}{/* ... 15 more conditional fields */}</form>);}
Every one of those helper functions is a copy of server logic. getCitesStatus duplicates the species registry. getDestinationsWithEnclosure duplicates a join across the facilities database. getRequiredCerts duplicates regulatory rules that change when legislation updates.
When the regulatory team adds a new quarantine rule for amphibians, the backend gets updated. The frontend doesn't hear about it for weeks. Meanwhile, a zookeeper submits a transfer that passes client validation but fails on the server with a raw 400 error that says nothing useful.
The deeper issue is architectural: business rules live in two places.
What counts as a business rule?
"A button opens a dialog" is not a business rule. That's interaction logic, and it belongs in the frontend. Business rules are domain constraints that determine what constitutes valid data: which combinations of fields are allowed, which values depend on other values, what conditions must hold before the system accepts an input. They exist because the domain says so (regulatory requirements, data integrity, organizational policy), not because the UI needs them for rendering. When these rules get duplicated across client and server, they drift.
Wait. Should this form even exist?
Before reaching for server-driven forms, ask a harder question: why does this form have so many cross-field dependencies in the first place?
A form where changing one dropdown reshapes five other fields is not just a validation problem. It's a UX problem. The user is being asked to make too many interdependent decisions on a single screen. A multi-step wizard that isolates decisions into sequential chunks eliminates most of the cascading complexity:
- Step 1: Pick a species. Lock it in. Move on.
- Step 2: Pick a destination. The server already knows the species, so it only shows valid destinations. No cascading. No disabled dropdowns. No "select a species first" helper text.
- Step 3: Transport and dates. The constraints are now scoped to the locked-in species + destination pair.
Each step is a simple form with no cross-field dependencies. Each step validates in isolation. The "if A changes then B must be different" rules vanish, because A is already committed before B appears.
Honest take
While researching this article, I shared the server-driven approach with a team that had exactly this problem. Their response: "This is overkill for us. We'll just duplicate the two business rules in the frontend and rethink the UX so the problem goes away." They were right. For their case, a cleaner form flow was the better fix.
Server-driven forms solve the "we have this complex form and the validation logic is duplicated" problem. Good UX design prevents the problem from existing. If you can break your form into steps, do that first. If you have tried and the complexity is genuinely irreducible (the user needs to see and adjust multiple related fields simultaneously, the form cannot be sequential, or you inherit a form that's already built this way), then read on.
The principle: server as single source of truth
If you've decided the form complexity is real and unavoidable, the fix is not better client-side validation. The fix is removing client-side business logic entirely. The frontend should handle only two things:
- UX validation: "this field is required", "this must be a number", "minimum 3 characters". Things that improve the experience but carry no business meaning.
- Rendering: displaying whatever the server says. Errors, allowed values, disabled states, constraint messages.
Everything else comes from the server. Which destinations have suitable enclosures for this species. Which transport methods are legal for this animal. Whether the current health certificate is recent enough. The server answers all of these questions, because the server already has access to all the data sources.
The pattern: server-generated JSON Schema
Rather than inventing a custom response format, the server speaks a language that an existing form renderer already understands: JSON Schema. The server generates a schema dynamically on every request, and the client passes it straight to a renderer. No custom mapping code, no hand-rolled field components.
The server returns three objects:
schema(JSON Schema): which fields exist, their types, their allowed values (oneOf), which are requireduiSchema: which fields are disabled, hidden, or have helper textextraErrors: field-level validation errors from the server
The client uses react-jsonschema-form (RJSF) with the MUI theme to render all three. The frontend becomes a pass-through.
Initial load (creating a new transfer)
The form mounts and sends the empty state to POST /transfer/form-state. The server returns:
json{"schema": {"type": "object","required": ["speciesId"],"properties": {"speciesId": {"type": "string","title": "Species","oneOf": [{ "const": "PAN_TROG", "title": "Chimpanzee" },{ "const": "CROC_NIL", "title": "Nile Crocodile" },{ "const": "DENDRO_AZU", "title": "Blue Poison Dart Frog" }]},"destinationId": {"type": "string","title": "Destination","oneOf": []},"transportMethod": {"type": "string","title": "Transport Method","oneOf": []}}},"uiSchema": {"destinationId": {"ui:disabled": true,"ui:help": "Select a species first"},"transportMethod": {"ui:disabled": true,"ui:help": "Select a species and destination first"}},"extraErrors": {}}
RJSF renders an enabled species dropdown and disabled destination/transport dropdowns with helper text. The frontend has zero opinion on why destination is disabled. It just renders what the server said.
After selecting "Chimpanzee"
The form sends the updated state. The server recalculates everything and returns a new schema:
json{"schema": {"type": "object","required": ["speciesId", "destinationId", "ministerialApprovalRef"],"properties": {"speciesId": {"type": "string","title": "Species","oneOf": [{ "const": "PAN_TROG", "title": "Chimpanzee" },{ "const": "CROC_NIL", "title": "Nile Crocodile" },{ "const": "DENDRO_AZU", "title": "Blue Poison Dart Frog" }]},"destinationId": {"type": "string","title": "Destination","oneOf": [{ "const": "ZOO_BER", "title": "Berlin Zoo" },{ "const": "ZOO_VIE", "title": "Vienna Zoo" }]},"transportMethod": {"type": "string","title": "Transport Method","oneOf": []},"ministerialApprovalRef": {"type": "string","title": "Ministerial Approval Reference"},"healthCertificateDate": {"type": "string","format": "date","title": "Health Certificate Date"}}},"uiSchema": {"transportMethod": {"ui:disabled": true,"ui:help": "Select a destination first"},"ministerialApprovalRef": {"ui:help": "CITES Appendix I: ministerial approval required"},"healthCertificateDate": {"ui:help": "Must be issued within 10 days of transport"}},"extraErrors": {}}
Selecting "Chimpanzee" (a CITES Appendix I species) cascaded through the entire form. Destinations narrowed to facilities with primate enclosures. A ministerial approval field appeared in the schema with a required constraint. A health certificate date field appeared with helper text explaining the freshness rule. The frontend didn't compute any of this. It received a new schema and RJSF re-rendered.
Editing an existing transfer
The same flow applies. The form loads with saved values as formData, calls
/form-state, and the server returns the schema based on those values. Some
fields may be ui:disabled because the transfer already received partial
approval. The frontend does not need a separate "edit mode." It renders
whatever the server returns.
Validation errors
When the user fills out the form but picks a transport date too close to the certificate date, the server returns errors via extraErrors:
json{"schema": { "..." },"uiSchema": { "..." },"extraErrors": {"healthCertificateDate": {"__errors": ["Health certificate will be older than 10 days on transport date"]},"ministerialApprovalRef": {"__errors": ["Ministerial approval reference is required for CITES Appendix I species"]}}}
RJSF renders these as MUI FormHelperText error messages under each field. The frontend doesn't calculate certificate validity windows. It doesn't know what CITES Appendix I means. The server knows, and RJSF displays what it returned.
The cascading update flow
Here's what happens when the zookeeper changes the species from "Chimpanzee" to "Nile Crocodile" mid-edit:
- User selects "Nile Crocodile" in the species dropdown
- Frontend sends the updated
formDatatoPOST /transfer/form-state - Server recalculates everything and returns a new schema:
- Destinations update (only facilities with reptile enclosures via new
oneOfvalues) - The previously selected "Vienna Zoo" may no longer be in the
oneOflist - Transport methods change (new
oneOfoptions) ministerialApprovalRefdisappears from the schema (Nile Crocodile is CITES Appendix II, not I)- Health certificate
ui:helpchanges to "Must be issued within 21 days of transport"
- Destinations update (only facilities with reptile enclosures via new
- Frontend passes the new schema to RJSF, which re-renders the entire form
If the previously selected destination is no longer in the oneOf list, RJSF shows it as invalid. The user picks a new destination, which triggers another round-trip, and the form settles into a valid state.
This is the key insight: the form is a conversation with the server. Each change is a question ("given what I've selected so far, what are my options?"), and the server answers with a complete JSON Schema.
Communicating constraints to the user
Notice the ui:help values in the responses above. These are not error
messages. They explain why a field exists or what rule applies, before the user
has done anything wrong. "Must be issued within 10 days of transport" helps the
zookeeper understand the requirement. "CITES Appendix I: ministerial approval
required" explains why a new field just appeared. Good server-driven forms
communicate rules proactively, not just reactively.
Who does this in production?
This is not a theoretical pattern. Several large-scale products rely on server-driven forms:
Atlassian Jira uses GET /rest/api/3/issue/createmeta to return the fields, allowed values, and required/optional status for creating an issue. When you change the issue type, the frontend calls the endpoint again to get the new field configuration. Every dropdown, every required field, every constraint comes from the server.
Salesforce built their entire platform on metadata-driven forms. Object schemas, field dependencies, validation rules, and page layouts are all server-owned. Lightning Dynamic Forms allow admins to configure form layouts declaratively, and the runtime fetches this configuration.
Stripe uses partially server-driven forms in their Payment Element. Available payment methods and their required fields are fetched from Stripe's servers based on merchant configuration and customer location. The frontend renders what comes back.
Google Cloud APIs support a validateOnly parameter on create methods (documented in AIP-163), which is exactly the dry-run validation pattern.
React implementation with RJSF
The entire frontend implementation is four packages and one small component. No custom field renderers, no mapping logic.
Setup
bashpnpm add @rjsf/core @rjsf/mui @rjsf/utils @rjsf/validator-ajv8
These are the only dependencies beyond what you already have (@mui/material, @mui/icons-material, Emotion). All four packages share the same version number (currently v6), so always install matching versions.
The form state hook
tsximport { useQuery } from "@tanstack/react-query";import type { RJSFSchema, UiSchema, ErrorSchema } from "@rjsf/utils";type FormStateResponse = {schema: RJSFSchema;uiSchema: UiSchema;extraErrors: ErrorSchema;};function useTransferFormState(formData: Record<string, unknown>) {return useQuery<FormStateResponse>({queryKey: ["transfer-form-state", formData],queryFn: async ({ signal }) => {const res = await fetch("/api/transfer/form-state", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify(formData),signal,});return res.json();},staleTime: 0,});}
The form component
tsximport { useState } from "react";import Form from "@rjsf/mui";import validator from "@rjsf/validator-ajv8";import { useDebouncedValue } from "./useDebouncedValue";function TransferForm({ initial }: { initial?: Record<string, unknown> }) {const [formData, setFormData] = useState(initial ?? {});const debouncedData = useDebouncedValue(formData, 300);const { data: formState } = useTransferFormState(debouncedData);if (!formState) return null;return (<Formschema={formState.schema}uiSchema={formState.uiSchema}extraErrors={formState.extraErrors}formData={formData}validator={validator}noValidateonChange={({ formData }) => setFormData(formData)}onSubmit={({ formData }) => submitTransfer(formData)}/>);}
That's it. The entire form component is 20 lines. No if (species === "PAN_TROG") anywhere. No getCitesStatus. No custom <Select> wrappers. RJSF reads the JSON Schema and renders MUI components: oneOf becomes a Select with MenuItems, string becomes a TextField, format: "date" becomes a date picker. The uiSchema controls disabled states and helper text. The extraErrors control error messages.
The noValidate prop tells RJSF to skip client-side JSON Schema validation entirely. The server is the only validator. You still pass the validator prop (RJSF requires it for internal type resolution), but it never blocks submission.
When to fire the request
Not on every keystroke. Fire immediately on dropdown selections, toggles, and
date pickers. Debounce text inputs at 300 to 500ms. The signal parameter
passed to queryFn allows TanStack Query to abort in-flight requests
automatically when the query key changes, so stale responses never overwrite
fresh ones.
Why RJSF?
We evaluated several schema-driven form renderers before settling on RJSF:
| Library | Schema format | Server errors | MUI support |
|---|---|---|---|
| react-jsonschema-form | JSON Schema + uiSchema | extraErrors prop (best) | Official @rjsf/mui |
| JSON Forms | JSON Schema + UI Schema | additionalErrors prop | Official renderers |
| data-driven-forms | Single flat schema | Final Form internals | Official mapper |
| uniforms | JSON Schema via bridge | Error object mapping | Official theme |
RJSF won for three reasons: the extraErrors prop is purpose-built for server-side validation (you pass field-keyed __errors arrays and they render as MUI FormHelperText), the noValidate prop cleanly disables client-side validation, and the schema can be swapped on every render without issues. When the server returns a completely different schema after a species change, RJSF rebuilds the form tree from scratch. No stale fields, no orphaned state.
What about OpenAPI and GraphQL?
If you're using code generation (as we discussed in Typesafe API Code Generation for React in 2026), you might wonder how this pattern fits.
OpenAPI
OpenAPI 3.0 cannot express conditional validation rules like "if species is CITES Appendix I, require ministerial approval." The schema is static. OpenAPI 3.1 adopted JSON Schema 2020-12, which supports if/then/else, dependentRequired, and dependentSchemas, but tooling support is still catching up. Code generators like Hey API and Orval do not generate conditional validation code from these keywords.
The pragmatic approach: use your OpenAPI spec for types and SDK generation, but treat the /form-state endpoint as a separate concern. Define it in your spec like any other endpoint. The response type wraps schema, uiSchema, and extraErrors as opaque JSON objects. Your generated SDK function returns them, and you pass them straight to RJSF.
yaml/transfer/form-state:post:summary: Get form schema, UI state, and validation for a transferrequestBody:content:application/json:schema:$ref: "#/components/schemas/TransferInput"responses:"200":content:application/json:schema:type: objectrequired: [schema, uiSchema, extraErrors]properties:schema:type: objectdescription: JSON Schema describing current form fieldsuiSchema:type: objectdescription: RJSF uiSchema for disabled/hidden/help statesextraErrors:type: objectdescription: RJSF ErrorSchema for server validation errors
GraphQL
GraphQL has the same limitation. The schema type system gives you non-null constraints and enum values, but cannot express cross-field conditional rules.
The pattern translates to a query that returns the three RJSF objects as JSON scalars:
graphqlscalar JSONinput TransferInput {speciesId: IDdestinationId: IDtransportMethod: StringtransportDate: DatehealthCertificateDate: DateministerialApprovalRef: String}type TransferFormState {schema: JSON!uiSchema: JSON!extraErrors: JSON!}type Query {transferFormState(input: TransferInput!): TransferFormState!}
The tradeoff is that JSON scalars are opaque to the GraphQL type system. You lose type safety on the response shape. In practice this is fine because RJSF is the consumer and it validates the schema structure at render time. The alternative (modeling every field state as a typed GraphQL object) is possible but creates a parallel type system that needs to stay in sync with RJSF's expectations.
Validation as part of the mutation
An alternative GraphQL pattern is returning validation errors as part of the mutation payload itself, using the "user errors as data" convention. Shopify and GitHub both do this. The mutation returns the created object OR a list of field-level errors. This works well for submit-time validation but does not help with real-time feedback while the user is filling out the form. For complex forms like the transfer request, you need both: the form-state query for real-time feedback and structured errors on the mutation for final submission.
Tradeoffs
This pattern is not free. Here's what you're trading:
Network latency on every field change. Every meaningful interaction fires a request. On slow connections or high-latency corporate VPNs, this is noticeable. Mitigations: aggressive debouncing, client-side pre-validation for obvious things (empty required fields), SWR caching, edge deployment.
No offline support. Server-driven forms fundamentally do not work offline. If the network is unavailable, the form cannot validate or update options. If you need offline-first forms, this pattern is the wrong choice.
Complexity in the contract. The backend now generates JSON Schema dynamically. This is more work than returning a flat list of errors, but the payoff is that the frontend needs zero custom rendering logic.
Testing requires a server. You can't test form behavior in isolation with unit tests alone. You need a running server or a mock that returns realistic form state responses. Integration tests become more important.
Where JSON Schema stops
JSON Schema cannot express "the allowed destinations depend on which facilities
have suitable enclosures for this species." That requires a database query, not
a static schema. The server handles this by generating a new schema with
updated oneOf values on every request. JSON Schema is the transport format,
not the rule engine. The rules live in your backend code. JSON Schema is just
how you communicate the result to RJSF.
When it's worth it
- Forms that pull data from multiple backend systems (species registries, facility databases, regulatory APIs)
- Complex cross-field dependencies that cascade (changing one field invalidates or reshapes others)
- Business rules that change frequently (new regulations, updated constraints)
- Forms with server-only validation (uniqueness checks, availability checks, external API calls)
- Multi-tenant platforms where each tenant has different form configurations
When it's not
- Simple static forms (login, contact, signup)
- Offline-first applications
- Forms where all rules can be expressed in a client-side schema (Zod, Yup)
- Forms with no cross-field dependencies
The mental model
| Question | Who answers |
|---|---|
| Which destinations have enclosures for this species? | Server (generates oneOf in schema) |
| Is the health certificate still valid on transport date? | Server (returns extraErrors) |
| Does this species require ministerial approval? | Server (adds field to schema + required) |
| Should the destination dropdown be disabled? | Server (sets ui:disabled in uiSchema) |
| How do I render all of the above? | RJSF (reads schema, uiSchema, extraErrors) |
| How do I show a loading state during re-validation? | Frontend (TanStack Query isFetching) |
| Should I debounce the weight input? | Frontend (useDebouncedValue) |
The frontend becomes a pass-through. The server generates a JSON Schema, a uiSchema, and an error object. RJSF renders them as MUI components. You write the validation logic exactly once on the backend, and you get consistent behavior whether the request comes from the React form, a mobile app, or a direct API consumer.
The only real cost is the extra network roundtrip on field change. For forms like the transfer request, where the rules span five different data sources and change with every regulatory update, that cost is not even a question.
Sources and further reading
- react-jsonschema-form (RJSF)
The schema-driven form renderer used in this article. The @rjsf/mui package provides the MUI theme.
- RJSF: Validation documentation
How extraErrors, noValidate, and custom validation work in RJSF.
- JSON Forms
Alternative schema-driven form renderer by EclipseSource. Uses JSON Schema + UI Schema with an additionalErrors prop.
- data-driven-forms
Red Hat's form renderer with a single flat schema format. Official MUI mapper available.
- uniforms
Schema-agnostic form renderer by Vazco. Bridges for JSON Schema, GraphQL, Zod, and more.
- TanStack Query
Data fetching library used for the form state hook. Handles caching, deduplication, and request cancellation.
- Atlassian Jira: Issue Create Meta API
Jira's endpoint that returns fields, allowed values, and required status per issue type. A production example of server-driven forms.
- Salesforce: Lightning Dynamic Forms
Salesforce's metadata-driven form system where admins configure layouts declaratively and the runtime fetches the configuration.
- Stripe: Payment Element
Stripe's server-driven form component that fetches available payment methods and required fields based on merchant config.
- Google AIP-163: Validate-only requests
Google's API design pattern for dry-run validation via a validateOnly parameter on create methods.
- Airbnb: A Deep Dive into Server-Driven UI
Airbnb's Ghost Platform, where the server sends screens and forms as declarative data and the client renders them generically.
- Shopify GraphQL Admin API
Shopify's 'user errors as data' convention, where mutations return structured field-level errors as part of the payload.
- JSON Schema 2020-12: Validation
The JSON Schema spec that introduced if/then/else, dependentRequired, and dependentSchemas for conditional validation.
- OpenAPI 3.1 Specification
The OpenAPI version that adopted JSON Schema 2020-12, enabling conditional validation keywords in API specs.
