Server and Full-Stack Validation
Client-side validation is the part of a form users see; server-side validation is the part that decides. Every rule the browser enforces must be enforced again on the server, the server must enforce rules the browser cannot know about — uniqueness, stock, permissions, fraud — and the result has to travel back across the network and land on the right field in a form the user can still understand. This section covers that whole round trip for full-stack JavaScript and TypeScript teams: how to write rules once and run them in both places, how an API should describe validation failures so any client can use them, how modern framework actions validate forms that work before JavaScript loads, and which security controls make validation meaningful rather than decorative. It is written for engineers who own both sides of a form — or who own one side and need a contract with the other — and it keeps the browser on the site’s canonical baseline: <form novalidate> with a single manual reportValidity() driven by the Constraint Validation API, with server errors arriving through the same setCustomValidity() channel as client ones.
The section has four topics. Shared client–server schemas removes the drift between two copies of the same rules. API validation error contracts defines how failures are reported — status codes, problem details, pointers to fields. Server actions and progressive enhancement applies both to Next.js, React Router and SvelteKit, and to plain HTML that works with no script at all. And validation security and abuse prevention draws the line between validation as user experience and the controls that actually protect a system.
Architecture: Where Server Validation Sits
A full-stack form has more layers than either side’s code suggests. Understanding which layer owns which rule is most of the design.
The key trade-offs between putting a rule in each place are consistent across forms:
| Rule lives in | Strength | Weakness | Examples |
|---|---|---|---|
| HTML attributes | Works before any script; free | Advisory; limited vocabulary | required, type, minlength |
| Client enhancement | Instant feedback, focus management | Bypassable; must match the server | Shared schema, strength meters, typo hints |
| Server guards | Cheap, generic, early | Not field-specific | Body limits, rate limits, honeypots |
| Shared schema (server run) | Authoritative, identical messages | Needs a shared module | Formats, lengths, cross-field rules |
| Domain rules | Can use data and permissions | Only on the server | Uniqueness, stock, ownership, pricing |
Three principles run through every topic in the section. The server re-checks everything, because the network is untrusted — why client-side validation is not security shows exactly how every client control is bypassed. Rules are written once, in a module both sides import, so re-checking does not mean re-implementing. Errors travel as data, with a stable code and a pointer to the field, so any client can put them in the right place.
Core API Surface Across the Boundary
The full-stack model touches a small set of APIs on each side. Knowing their exact behaviour avoids most integration bugs.
| API | Side | Type / signature | Default behaviour | Notes |
|---|---|---|---|---|
schema.safeParse(input) |
both | (unknown) => SafeParseResult |
Never throws | Same call, same messages, both sides |
request.formData() |
server | () => Promise<FormData> |
Buffers the body | Put size limits in front of it |
Object.fromEntries(fd) |
both | (FormData) => object |
Last value wins for repeats | Use getAll for multi-value fields |
422 Unprocessable Content |
server | HTTP status | — | The validation status; body lists field errors |
application/problem+json |
server | media type | — | RFC 9457 error bodies |
fetch() |
client | Promise<Response> |
Resolves on 4xx/5xx | Only network errors reject |
form.elements.namedItem(name) |
client | Element | RadioNodeList | null |
— | Maps an error path to its control |
setCustomValidity(msg) |
client | (string) => void |
"" = valid |
The single channel for every error |
reportValidity() |
client | () => boolean |
Focuses first invalid | Announces the message |
Canonical Implementation: One Rule Set, One Contract, One Renderer
The pattern below ties the section together in about eighty lines: a shared schema, a server handler that returns an RFC 9457-style contract, and a client that renders client- and server-side failures through the same function.
// shared/contact.ts — imported by browser and server
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(1, "Enter your name."),
email: z.string().trim().email("Enter an email address like name@example.com."),
message: z.string().trim().min(10, "Your message must be at least 10 characters.").max(5000, "Your message must be 5,000 characters or fewer."),
});
export interface FieldProblem { pointer: string; code: string; detail: string }
export interface ValidationProblem { type: "/problems/validation"; title: string; status: 422; errors: FieldProblem[] }
export const toProblems = (issues: z.ZodIssue[]): FieldProblem[] =>
issues.map((i) => ({ pointer: "/" + i.path.join("/"), code: `schema.${i.code}`, detail: i.message }));
// server/contact.ts
import { contactSchema, toProblems, type ValidationProblem } from "../shared/contact";
export async function POST(request: Request): Promise<Response> {
if (Number(request.headers.get("content-length") ?? 0) > 32 * 1024) return new Response(null, { status: 413 });
assertSameOrigin(request); // CSRF guard
if (await isRateLimited(request)) return new Response(null, { status: 429, headers: { "retry-after": "60" } });
const fd = await request.formData();
if (looksAutomated(fd)) return new Response(null, { status: 303, headers: { location: "/contact/sent" } });
const parsed = contactSchema.safeParse(Object.fromEntries(fd));
if (!parsed.success) {
const body: ValidationProblem = { type: "/problems/validation", title: "Your message has invalid fields.", status: 422, errors: toProblems(parsed.error.issues) };
const wantsJson = request.headers.get("accept")?.includes("json");
return wantsJson
? Response.json(body, { status: 422, headers: { "content-type": "application/problem+json" } })
: new Response(renderContactPage(Object.fromEntries(fd), body.errors), { status: 422, headers: { "content-type": "text/html" } });
}
await deliver(parsed.data);
return new Response(null, { status: 303, headers: { location: "/contact/sent" } });
}
// client/contact.ts — enhancement layer
import { contactSchema, toProblems, type FieldProblem } from "../shared/contact";
const form = document.querySelector<HTMLFormElement>("#contact")!;
function render(problems: FieldProblem[]): boolean {
for (const el of form.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[name]")) el.setCustomValidity("");
for (const p of problems) {
const el = form.elements.namedItem(p.pointer.slice(1).split("/").join("."));
if (el && "setCustomValidity" in el) {
(el as HTMLInputElement).setCustomValidity(p.detail);
el.addEventListener("input", () => (el as HTMLInputElement).setCustomValidity(""), { once: true });
}
}
return form.reportValidity();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const local = contactSchema.safeParse(Object.fromEntries(new FormData(form)));
if (!render(local.success ? [] : toProblems(local.error.issues))) return; // client pass
const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { accept: "application/json" } });
if (res.status === 422) return void render((await res.json()).errors); // server pass, same renderer
if (res.ok || res.redirected) window.location.assign(res.url);
});
The same render function handles the client’s own findings and the server’s 422, so the user cannot tell — and does not need to know — which side caught the problem. The same handler serves a no-script post by rendering HTML, so the form works before the enhancement loads.
Accessible UX Integration Across the Round Trip
A server round trip changes when errors arrive and, on the no-script path, how the page is delivered. Four accessibility practices keep the experience equivalent to client-side validation.
Deliver server errors through the same channel. Apply them with setCustomValidity() and call reportValidity(), so focus moves to the first failing field in document order and its message is announced — exactly as for a client-side failure. WCAG 3.3.1 and 3.3.3 make no distinction between the two.
Make the no-script error page self-explanatory. Without a script to move focus, the re-rendered page must do the work: a title beginning “Error:”, an error summary as the first element of the form with links to each field, and errors adjacent to their inputs. The pattern is detailed in progressive enhancement without JavaScript and building an accessible error summary.
Preserve input. WCAG 3.3.7 Redundant Entry is directly relevant: an error round trip that clears the form forces users to re-enter everything. Echo non-sensitive values back; never echo secrets.
Announce pending and form-level states once. Use aria-busy on the form while submitting and a single role="alert" region for form-level failures such as a 409 conflict or a 429 limit, rather than stacking toasts, banners and field messages for the same event.
Cross-Runtime Strategy and Fallbacks
Full-stack validation code runs in more environments than browser-only code: several browsers, one or more server runtimes, sometimes edge functions with restricted APIs. Keep the shared layer to the intersection.
| Concern | Browser | Node | Edge runtimes | Strategy |
|---|---|---|---|---|
| Schema library | Yes | Yes | Yes | Pure ESM, no platform imports |
FormData parsing |
Yes | 18+ | Yes | Same converter both sides |
crypto |
crypto.subtle |
node:crypto and subtle |
subtle |
Use Web Crypto in shared code |
Intl.Segmenter |
Yes | 16+ | Varies | Feature-detect; fall back to code points |
| Regex engine | Backtracking | Backtracking | Backtracking | Linear patterns + length caps everywhere |
| Rate-limit state | — | Redis etc. | Platform KV / Durable Objects | Keep limiter behind an interface |
Feature-detect in shared code rather than assuming, and keep anything that needs a server resource — databases, secrets, rate-limit stores — out of the shared module entirely, enforced by an import-boundary lint rule as shown in sharing Zod schemas in a monorepo.
Framework Integration Patterns
Each full-stack framework gives the same three pieces — an action, a way to return errors, and an enhancement mechanism — under different names.
// Next.js (App Router): server action + useActionState
"use server";
export async function save(_prev: State, fd: FormData): Promise<State> {
const parsed = schema.safeParse(Object.fromEntries(fd));
return parsed.success ? (await persist(parsed.data), redirect("/done")) : { errors: fieldErrors(parsed.error), values: echo(fd) };
}
// React Router 7: route action returning data(..., { status: 422 })
export async function action({ request }: Route.ActionArgs) {
const parsed = schema.safeParse(Object.fromEntries(await request.formData()));
return parsed.success ? redirect("/done") : data({ errors: fieldErrors(parsed.error) }, { status: 422 });
}
// SvelteKit 2: form action returning fail(422, …)
export const actions = {
default: async ({ request }) => {
const parsed = schema.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return fail(422, { errors: fieldErrors(parsed.error) });
redirect(303, "/done");
},
};
In every case the component reads the returned errors, renders them beside the fields with aria-describedby, and mirrors them into setCustomValidity() so reportValidity() handles focus. The framework-specific guides are Next.js server actions with useActionState, React Router action validation and SvelteKit form actions validation. Client-side form libraries — React Hook Form, VeeValidate, Angular Reactive Forms — plug into the same contract through their “set server error” APIs, covered in framework integration patterns.
Automated Testing Strategy
Full-stack validation needs tests on both sides and across the boundary, because the most expensive bugs are mismatches between correct-looking halves.
Unit tests for the shared schema, driven by FormData fixtures rather than JavaScript objects, so the tests see the real wire format — strings, missing checkbox keys, repeated fields. One suite covers both runtimes because the code is the same.
Contract tests per route: post a known-invalid body to the real handler and assert the status, the media type and the exact {pointer, code} list. Compare it with safeParse on the same input to prove the server did not drift from the shared rules.
import { describe, it, expect } from "vitest";
import { contactSchema, toProblems } from "../shared/contact";
import { POST } from "../server/contact";
describe("contact route contract", () => {
it("returns the shared schema's problems as 422 problem details", async () => {
const body = new FormData();
body.set("name", "");
body.set("email", "not-an-email");
body.set("message", "hi");
const res = await POST(new Request("https://site.test/contact", { method: "POST", body, headers: { accept: "application/json", origin: "https://site.test" } }));
expect(res.status).toBe(422);
expect(res.headers.get("content-type")).toContain("application/problem+json");
const expected = toProblems((contactSchema.safeParse(Object.fromEntries(body)) as any).error.issues);
expect((await res.json()).errors).toEqual(expected);
});
});
Browser tests on both paths: run the Playwright suite with JavaScript enabled and disabled. The no-script run proves errors, values, summary and title all come back from the server; the scripted run proves client and server errors render identically. Add accessibility audits of the error states with axe-core, as in automating axe-core form audits in CI.
Hostile-request tests: for each route, one test that removes client constraints or adds forbidden fields, proving the server ignores or rejects them. These tests are short, but they are the only automated evidence that the server does not quietly depend on something the browser was supposed to enforce.
Deciding Where a New Rule Belongs
Most full-stack validation bugs start as a reasonable rule added in the wrong layer. A product manager asks for “no bookings more than 12 months ahead”, and it ends up as a max attribute on a date input and nowhere else; a security review asks for “limit sign-up attempts”, and it ends up as a disabled button. A short decision procedure prevents most of these.
The procedure has one more step that is easy to forget: after placing the rule, decide how its failure is reported. Shared-schema and domain-rule failures are 422s with pointers; guard failures are other statuses (413, 403, 429) with form-level messages; client-only rules never block submission at all.
Handling Failures That Are Not Validation
A form’s submission can fail for reasons that have nothing to do with the data: the session expired, the record changed underneath the user, a payment provider timed out, the server is overloaded. Treating these as validation errors — or worse, showing a generic “Something went wrong” for all of them — leaves users unable to recover. Each needs its own status and its own recovery: 401 prompts re-authentication while preserving a draft; 409 shows the latest version and explains the conflict; 429 and 503 explain when to try again; 5xx apologises and keeps the input. The client code in handling 422 Unprocessable Content responses branches on status for exactly this reason, and the problem-details type URI lets a client distinguish two different 409s. The guiding rule is that no failure should ever lose the user’s input or leave the submit button stuck.
Observability for Full-Stack Validation
Once validation runs on the server, it produces a data stream worth watching. Emit a counter per route, error code and outcome — never the submitted values — and review it regularly. Spikes in a particular code after a deploy usually mean a rule got stricter than intended; a steady high rate for one field suggests the rule or its message is confusing real users; floods of one code from a narrow range of clients point to abuse. Correlate server 422s with the client’s own validation events: if the server frequently rejects values the client accepted, the two halves have drifted or a server-only rule deserves an earlier client-side hint. And log the instance identifier from each problem document so support staff can find the exact failing request from a user’s screenshot, as described in problem details (RFC 9457) for field errors.
Submitting FormData Versus JSON
Enhanced forms can submit either the raw FormData or a JSON object built from it, and the choice affects validation more than it seems. Submitting FormData keeps the enhanced path identical to the no-script path: the server parses one format, the shared schema models one wire representation (strings, "on" for checkboxes, repeated keys for multi-selects), and files travel in the same request. Submitting JSON lets the client send richer types — numbers, booleans, nested arrays — but creates a second format the server must accept, and the schema must then describe both or the no-script path breaks. For forms, prefer FormData end to end and let the schema coerce types; reserve JSON for API clients that never had an HTML form. The parsing details, including nested names and files, are in validating FormData on the server with Zod.
Idempotency and Double Submission
Server validation interacts with retries in a way client validation never does. A user who double-clicks, a flaky network that retries a POST, or a user who presses back and resubmits can all send the same form twice. If the first request succeeded, the second may fail validation for a confusing reason — “that username is taken” by the user’s own new account, “coupon already used” by their own order. Give state-changing forms an idempotency key: a random token rendered into a hidden field, stored with the result of the first successful request, and checked on every subsequent one. A repeated key returns the original success rather than re-running validation against data the first request changed. On the client, pair this with the submission guards in preventing double form submission; on the server, the key turns an accidental duplicate into a harmless no-op instead of a misleading validation error.
A Latency Budget for Server Validation
Server validation adds a round trip to the moment a user learns about a problem, so budget for it. Generic guards and schema parsing should cost well under ten milliseconds; they are pure computation on a small body. Domain rules that hit a database — uniqueness, stock — should use indexed lookups and run in parallel where independent. The total server time for a validation failure should be small enough that the round trip is dominated by the network, which on a mobile connection is already a few hundred milliseconds. That is precisely why the client runs the shared schema first: it removes the round trip for the common mistakes, leaving the network only for the rules that genuinely need the server. If a server-only rule is both slow and frequently failed — “that username is taken” is the classic example — give it an asynchronous, rate-limited client hint so users hear about it while typing rather than after submitting.
Migrating an Existing Form to This Model
Most teams arrive here with a form that already has client validation in components and ad hoc checks in an API handler. A safe migration takes four steps, each shippable on its own. First, write the shared schema from the server’s current rules, since those are what the system actually enforces, and start using it on the server with the existing error format. Second, change the server’s error format to the 422 contract, keeping a compatibility shim if other clients depend on the old shape. Third, replace the component’s hand-written client rules with the shared schema, fixing any differences in favour of the server’s behaviour and noting them in the changelog. Fourth, add the no-script path and the contract tests. At no step do users lose validation, and after the last step the two halves can no longer drift apart silently. The sequencing rules for tightening or loosening rules while old tabs remain open are covered in shared client–server schemas.
Implementation Checklist
Frequently Asked Questions
Why validate on the server if the client already validates?
Because the client is under the user's control. Any browser check can be removed or bypassed, and requests can be sent without a browser at all. Client validation is for user experience; server validation is what the system relies on.
How do I avoid writing validation rules twice?
Put them in a shared schema module that both the browser bundle and the server import, then add server-only checks such as uniqueness after the shared schema passes.
What should a validation error response look like?
A 422 status with an application/problem+json body listing each error with a JSON Pointer to the field, a stable code and a human message, so any client can place it on the right input.
Do framework server actions replace client-side validation?
They replace the need for client-side validation to be correct, because the action validates everything. Client-side checks from the same schema remain valuable for instant feedback once JavaScript has loaded.
Related Guides
- Shared Client–Server Schemas — one rule set for both sides.
- API Validation Error Contracts — status codes, problem details and pointers.
- Server Actions and Progressive Enhancement — framework actions and no-script forms.
- Validation Security and Abuse Prevention — the controls that make validation meaningful.
- Handling Server Validation Errors After Submit — the native submission lifecycle this section extends.