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.

The full-stack validation round trip A form's values are checked in the browser with a shared schema, sent to the server, re-validated with the same schema plus server-only rules, and any failures return as a structured error contract that the browser maps back onto fields. Browser check shared schema, reportValidity Request FormData or JSON Server check same schema + server-only rules Error contract 422, problem details, pointers Back on the field setCustomValidity, focus
One schema, two runs, one error contract: the user sees the same kind of message on the same field whichever side found the problem.

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.

Layers of a full-stack form Six layers from the HTML form through client enhancement, the network, the server's generic guards, its schema validation, and domain rules with storage. HTML form names, types, labels, action — works with no script Client enhancement shared schema, reportValidity, async hints Network untrusted: any client can send anything Server guards size limits, origin checks, bot signals, rate limits Schema validation shared schema: formats, lengths, cross-field rules Domain rules + storage uniqueness, stock, permissions, derived values
Generic guards run before any parsing, schema validation before any business logic, and every error from any layer returns in one contract.

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.

Client pass, server pass, one renderer The enhancement runs the shared schema locally and renders any failures; if the local pass succeeds it submits, and a server 422 is rendered by the same function. User Enhancement Server submit shared schema → render(problems) POST FormData (local pass succeeded) guards, schema, domain rules 422 { errors: [pointer, detail] } render(errors) → focus + announcement
Two independent checks, one presentation: messages, focus and announcements are identical whichever side found the problem.

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.

A server-rendered error state A contact form re-rendered by the server after a failed submission, with an error summary, errors beside the fields, preserved values and a note on focus and title behaviour. Contact us There are 2 problems — Enter an email address like name@example.com · Your message must be at least 10 characters 1 Your name Ada Lovelace Email address ada@example 2 ✗ Enter an email address like name@example.com Message Hello 3 ✗ Your message must be at least 10 characters Send message 1 Summary first in the form, role="alert", links to each field 2 Same message text as the client check, from the shared schema 3 Values echoed back; the page title starts with "Error:"
With or without JavaScript, the user lands on the same information: a summary, field errors and their own values.

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.

Testing the round trip Schema unit tests with FormData fixtures, per-route contract tests, browser tests with and without JavaScript, and hostile-request tests together cover both halves and the boundary. Schema units FormData fixtures Route contracts status, type, pointers Browser, JS on same rendering both sides Browser, JS off summary, values, title Hostile requests extra fields, no constraints
The contract test is the one that catches drift between two individually correct halves.

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.

Where should a new validation rule live? A decision tree placing a new rule in the shared schema, in server-only domain logic, in generic server guards, or in client-only experience code depending on what the rule needs. Does the server reject requests that break it? no Client-only experience code yes Does it need only the submitted values? yes Shared schema (both sides) no Is it about the request, not the data? yes Server guard: size, rate, origin no Server domain rule: DB, identity
Rules that need only the submitted values go in the shared schema; rules that need data or identity stay on the server; experience features stay on the client.

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.

← Back to Home

Explore This Section