2026

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.

S
Sascha Becker
Author

20 min read

Server-Driven Forms: Stop Duplicating Validation Logic

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:

tsx
function TransferForm({ transfer }: { transfer: Transfer }) {
const [values, setValues] = useState(transfer);
// duplicated from backend
const 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.

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:

  1. Step 1: Pick a species. Lock it in. Move on.
  2. 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.
  3. 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.

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:

  1. UX validation: "this field is required", "this must be a number", "minimum 3 characters". Things that improve the experience but carry no business meaning.
  2. 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:

  1. schema (JSON Schema): which fields exist, their types, their allowed values (oneOf), which are required
  2. uiSchema: which fields are disabled, hidden, or have helper text
  3. extraErrors: 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.

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:

  1. User selects "Nile Crocodile" in the species dropdown
  2. Frontend sends the updated formData to POST /transfer/form-state
  3. Server recalculates everything and returns a new schema:
    • Destinations update (only facilities with reptile enclosures via new oneOf values)
    • The previously selected "Vienna Zoo" may no longer be in the oneOf list
    • Transport methods change (new oneOf options)
    • ministerialApprovalRef disappears from the schema (Nile Crocodile is CITES Appendix II, not I)
    • Health certificate ui:help changes to "Must be issued within 21 days of transport"
  4. 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.

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
bash
pnpm 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
tsx
import { 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
tsx
import { 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 (
<Form
schema={formState.schema}
uiSchema={formState.uiSchema}
extraErrors={formState.extraErrors}
formData={formData}
validator={validator}
noValidate
onChange={({ 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.

Why RJSF?

We evaluated several schema-driven form renderers before settling on RJSF:

LibrarySchema formatServer errorsMUI support
react-jsonschema-formJSON Schema + uiSchemaextraErrors prop (best)Official @rjsf/mui
JSON FormsJSON Schema + UI SchemaadditionalErrors propOfficial renderers
data-driven-formsSingle flat schemaFinal Form internalsOfficial mapper
uniformsJSON Schema via bridgeError object mappingOfficial 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 transfer
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/TransferInput"
responses:
"200":
content:
application/json:
schema:
type: object
required: [schema, uiSchema, extraErrors]
properties:
schema:
type: object
description: JSON Schema describing current form fields
uiSchema:
type: object
description: RJSF uiSchema for disabled/hidden/help states
extraErrors:
type: object
description: 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:

graphql
scalar JSON
input TransferInput {
speciesId: ID
destinationId: ID
transportMethod: String
transportDate: Date
healthCertificateDate: Date
ministerialApprovalRef: 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.

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.

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

QuestionWho 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


S
Written by
Sascha Becker
More articles