How to design a scalable form validation system

Most validation systems work for the first form. You add a required check here, a format check there, some error state in the component. It ships. It works.
Then you add a second form. You copy the pattern. Then a third. Now you're maintaining three different validation implementations, each slightly different. One returns strings, one returns arrays, one has a different key structure. Your frontend handles them with three different code paths.
Then you need to add localization. Or you want to share validation logic between a web form and a mobile app. Or you hire someone new and they write the fourth form their own way.
This is when "it works" stops being enough.
The problem: no contract between frontend and backend
Most form validation breaks down at the same place: there's no agreed-upon interface between the server and the client.
The backend returns whatever it returns. The frontend parses what it gets. Each form has its own implicit contract, usually undocumented, often inconsistent.
When the contract is implicit, every change to the backend requires a coordinated change to the frontend. There's no way to validate that they're still in sync. No way to write a test for the contract itself.
A scalable validation system makes the contract explicit. The backend always returns the same shape. The frontend always expects that shape. Neither side needs to know how the other works.
Two layers, two jobs
Before defining the contract, it's worth being clear about what each layer is actually responsible for.
Frontend validation is about UX. It catches errors before the user has to wait for a round trip. It gives immediate feedback when a field is left empty or an email address looks wrong. Its job is to reduce friction.
Frontend validation is not a security layer. Anything you validate on the client must also be validated on the server, because a client can always be bypassed.
Backend validation is about correctness and security. It's the authoritative check. It verifies that the data actually meets your requirements regardless of how it arrived. It runs on every submission, whether from your form, a curl command, or a third-party integration.
The two layers serve different purposes. Both are necessary. But they shouldn't be duplicated haphazardly. The goal is to define validation rules once and use them in both places.
Defining a validation contract
The contract is the shape your API returns when validation fails. Decide on it once and use it everywhere:
// types/validation.ts
export type ValidationResult =
| { success: true }
| {
success: false;
errors: Record<string, string>;
error?: string; // request-level error (rate limit, server error, etc.)
};A success: true response means the submission was accepted. A success: false response includes field-level errors keyed by field name, and optionally a request-level error for failures that don't map to a specific field.
This shape is small, predictable, and covers most scenarios. If you need to return multiple errors per field, change the value type to string[]. Pick one and keep it consistent.
Implementing the contract with Zod
Zod is a good choice for defining validation schemas in TypeScript because the same schema runs on both the server and the client:
// lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(1, "Name is required."),
email: z.string().email("A valid email address is required."),
message: z.string().min(10, "Message must be at least 10 characters."),
});
export type ContactInput = z.infer<typeof contactSchema>;The API route validates against this schema and returns the contract shape:
// app/api/contact/route.ts
import { contactSchema } from "@/lib/schemas/contact";
import type { ValidationResult } from "@/types/validation";
export async function POST(req: Request): Promise<Response> {
const formData = await req.formData();
const raw = Object.fromEntries(formData);
const result = contactSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
const fieldErrors: Record<string, string> = {};
for (const [key, messages] of Object.entries(errors)) {
if (messages?.[0]) fieldErrors[key] = messages[0];
}
return Response.json(
{ success: false, errors: fieldErrors } satisfies ValidationResult,
{ status: 400 }
);
}
// Process valid submission...
return Response.json({ success: true } satisfies ValidationResult);
}The satisfies ValidationResult annotation catches any accidental drift from the contract at compile time.
The React component uses the same schema for client-side feedback and parses the same contract shape on error:
// components/ContactForm.tsx
"use client";
import { useState } from "react";
import { contactSchema } from "@/lib/schemas/contact";
import type { ValidationResult } from "@/types/validation";
export function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
function getClientErrors(formData: FormData): Record<string, string> {
const raw = Object.fromEntries(formData);
const result = contactSchema.safeParse(raw);
if (result.success) return {};
const fieldErrors: Record<string, string> = {};
for (const [key, messages] of Object.entries(
result.error.flatten().fieldErrors
)) {
if (messages?.[0]) fieldErrors[key] = messages[0];
}
return fieldErrors;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (submitting) return;
const formData = new FormData(e.currentTarget);
// Client-side check first (UX, no round trip)
const clientErrors = getClientErrors(formData);
if (Object.keys(clientErrors).length > 0) {
setErrors(clientErrors);
return;
}
setSubmitting(true);
setErrors({});
try {
const res = await fetch("/api/contact", {
method: "POST",
body: formData,
});
const data: ValidationResult = await res.json();
if (!data.success) {
setErrors(data.errors ?? {});
return;
}
// Handle success
} catch {
setErrors({ _form: "Could not reach the server. Please try again." });
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
{/* fields with error rendering */}
</form>
);
}The schema is defined once. The contract is defined once. Both sides use both.
Scaling considerations
Once this pattern is established, adding new forms is mostly configuration:
Adding a new form: create a new Zod schema in lib/schemas/. The API route and component both import from it. The contract shape is already defined.
Localization: have the schema return error codes, and let the frontend handle translation:
// lib/schemas/contact.ts
export const contactSchema = z.object({
email: z.string().email({ message: "INVALID_EMAIL" }),
message: z.string().min(10, { message: "MESSAGE_TOO_SHORT" }),
});The frontend maps codes to locale-specific strings. The backend doesn't need to know about locales.
Cross-platform sharing: if you have a React Native app or a separate frontend, publish your schemas as a shared package. Both consume the same validation rules and the same contract. Changes to the schema propagate everywhere at once.
Testing the contract: because the contract is a TypeScript type, you can write tests that verify a given API response matches the expected shape:
const result: ValidationResult = await response.json();
expect(result.success).toBe(false);
if (!result.success) {
expect(result.errors).toHaveProperty("email");
}Validation is a system, not a feature
A form that validates inputs is a feature. A system where every form shares a contract, validation rules are defined once and run everywhere, and error responses are predictable across the entire API is infrastructure.
The difference shows up at the third form, or when you need to add localization, or when a new developer joins and writes code that integrates seamlessly because the contract exists and is enforced.
The investment is small: a type, a schema library, a consistent response shape. The payoff is that validation becomes a solved problem you can stop thinking about.
For a more tactical look at AJAX-specific issues, see AJAX form validation: common mistakes and how to fix them. For the full picture of what makes form handling production-ready, see the hidden complexity of handling form submissions.
Want form validation infrastructure without building it yourself?
Formtorch handles validation, error responses, and submission storage so you can focus on your product.

