Shared Client–Server Schemas

Every form with client-side validation has a second, invisible copy of its rules on the server — or it has a security hole. The trouble starts when those two copies drift. The client allows 64-character usernames, the server 32; the client trims whitespace, the server does not; the client’s message says “at least 8 characters” while the server’s says “Password too short”; a new optional field is added to the client schema and the server rejects the unknown key. Each drift produces the worst kind of validation failure: one the user cannot fix, because the form they can see says everything is fine. This topic shows how to write the rules once — as a schema in a module with no DOM or server dependencies — and run the same code in both places, with messages, normalisation and error shapes that match exactly. It builds on the site’s Zod schema validation guides and keeps the browser side on the canonical <form novalidate> plus Constraint Validation API baseline.

Shared schemas are also the backbone of the other topics in this section: the error format in API validation error contracts is simply what a shared schema’s failure looks like on the wire, and the server actions and progressive enhancement patterns run the same schema inside framework actions.

The problem this solves is organisational as much as technical: two teams, or one developer on two days, maintaining two rule sets that are supposed to be identical. Shared schemas make “identical” a property of the build rather than a hope.

One schema, two runtimes A shared schema module is imported by the browser bundle, where it drives field errors through setCustomValidity, and by the server route, where it parses FormData and returns field-keyed errors in the same shape. Shared schema module rules, messages, normalisation Browser bundle safeParse on input and submit Field errors setCustomValidity + aria-describedby Server route safeParse( FormData) → 422 Same error shape { field: message } both ways
The same module produces the same messages in both places, so a server rejection reads exactly like the client error the user would have seen.

Prerequisites for Shared Schemas

Requirement Minimum version Why it is needed
TypeScript 5.0+ Inferred types from the schema on both sides
Zod 3.23+ (or 4.x) safeParse, superRefine, transform, flatten()
A monorepo or package boundary npm/pnpm workspaces, or a single app with shared folders One source file imported by both builds
Bundler tree-shaking Vite, esbuild, webpack 5 Keeps server-only imports out of the client bundle
FormData on the server Node 18+, Deno, Bun, edge runtimes Parsing progressive-enhancement submissions
Lint rule for imports eslint-plugin-import or no-restricted-imports Stops the shared module importing DOM or server APIs

Shared Schema API Reference

API Type Returns Notes
schema.safeParse(input) (unknown) => SafeParseResult<T> { success, data } | { success, error } Never throws; use on both sides
error.flatten() () => { formErrors: string[]; fieldErrors: Record<string, string[]> } Field-keyed messages The wire format for field errors
z.infer<typeof schema> type Parsed output type Shared DTO type for client and server
z.input<typeof schema> type Pre-transform input type What the form actually sends
.transform(fn) method New schema Normalisation that must happen identically
.superRefine((v, ctx) => …) method New schema Cross-field rules with explicit path
Object.fromEntries(formData) (FormData) => Record<string, FormDataEntryValue> Plain object Single-value fields only; use getAll for repeats
z.coerce.number() schema Number from string Form values always arrive as strings

Step-by-Step Implementation

1. Put the schema in a module with no platform imports

// packages/validation/src/signup.ts — imported by web and api
import { z } from "zod";

export const MESSAGES = {
  emailRequired: "Enter your email address.",
  emailInvalid: "Enter an email address like name@example.com.",
  passwordShort: "Password must be at least 12 characters.",
  passwordLong: "Password must be 128 characters or fewer.",
  termsRequired: "You must accept the terms to create an account.",
} as const;

export const signupSchema = z.object({
  email: z
    .string()
    .trim()
    .min(1, MESSAGES.emailRequired)
    .email(MESSAGES.emailInvalid)
    .transform((v) => v.toLowerCase()),
  password: z
    .string()
    .transform((v) => v.normalize("NFKC"))
    .pipe(z.string().min(12, MESSAGES.passwordShort).max(128, MESSAGES.passwordLong)),
  terms: z.literal("on", { errorMap: () => ({ message: MESSAGES.termsRequired }) }),
});

export type SignupInput = z.input<typeof signupSchema>;
export type Signup = z.output<typeof signupSchema>;

No document, no window, no database client, no framework imports. That constraint is what makes the module safe to ship to the browser and fast to load on the server. Enforce it with a lint rule rather than trusting code review.

2. Use it in the browser without abandoning native validation

import { signupSchema } from "@acme/validation/signup";

const form = document.querySelector<HTMLFormElement>("#signup")!;

function applyErrors(fieldErrors: Record<string, string[] | undefined>): void {
  for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
    const message = fieldErrors[el.name]?.[0] ?? "";
    el.setCustomValidity(message);
    el.toggleAttribute("aria-invalid", Boolean(message));
    const out = document.getElementById(`${el.id}-err`);
    if (out) { out.textContent = message; out.hidden = !message; }
  }
}

form.addEventListener("submit", (event) => {
  const result = signupSchema.safeParse(Object.fromEntries(new FormData(form)));
  applyErrors(result.success ? {} : result.error.flatten().fieldErrors);
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();           // focus + announce the first failing field
  }
});

The schema produces the messages; the native API delivers them. This is the same division of labour used throughout the site, and it means the schema never needs to know about focus, ARIA or rendering.

3. Use it on the server with the same input shape

// apps/api/src/routes/signup.ts
import { signupSchema } from "@acme/validation/signup";

export async function POST(request: Request): Promise<Response> {
  const formData = await request.formData();
  const result = signupSchema.safeParse(Object.fromEntries(formData));
  if (!result.success) {
    return Response.json({ errors: result.error.flatten().fieldErrors }, { status: 422 });
  }
  const { email, password } = result.data;     // already trimmed, lower-cased, normalised
  // …server-only checks: uniqueness, breach corpus, rate limits
  return Response.json({ ok: true }, { status: 201 });
}

The server receives the same FormData a no-JavaScript submission would send, so this one route serves both progressive-enhancement posts and script-driven fetch calls. The recipe with multi-value fields and files is validating FormData on the server with Zod.

4. Map server errors back through the same function

const res = await fetch("/api/signup", { method: "POST", body: new FormData(form) });
if (res.status === 422) {
  const { errors } = await res.json();
  applyErrors(errors);               // identical rendering to client-side failures
  form.reportValidity();
}

Because the server returns exactly the structure flatten().fieldErrors produces, the client needs no special code for server errors. The wider contract, including form-level errors and problem-details responses, is covered in mapping server field errors to form inputs.

A request that bypassed client validation A crafted request skips the browser checks; the server parses it with the shared schema and returns the same field-keyed messages the client would have produced, which the page renders through the same function. Browser Shared schema API route POST FormData (client checks skipped) safeParse(Object.fromEntries(formData)) fieldErrors.password: "at least 12 characters" 422 { errors } applyErrors() → setCustomValidity → reportValidity
Whether an error is caught in the browser or on the server, the user sees the same message on the same field.

State Management and Edge Cases

Sharing a schema does not mean both sides run everything. Some rules can only run on one side, and the schema design has to make that split explicit.

Server-only rules. Uniqueness (“email already registered”), rate limits and breach-corpus lookups need the database or secrets. Keep them out of the shared schema and run them after safeParse succeeds on the server, returning errors in the same shape. On the client, the matching check is an asynchronous hint at most, as in implementing async email availability checks.

Client-only rules. Some checks only make sense with a UI — “confirm password matches” when the server never receives the confirmation field, or a file’s pixel dimensions measured before upload. Put them in a client-only extension of the shared schema with .extend() or .superRefine(), never in the shared base.

Async refinements. Zod supports asynchronous refinements with parseAsync, and it is tempting to put an availability lookup inside the shared schema that way. Resist it: the browser version would fire a request on every parse, including every keystroke, and the server version would duplicate the uniqueness logic that belongs next to the database’s unique index. Keep the shared schema synchronous, so safeParse is always cheap, and add asynchronous work explicitly at the edges where you control its timing.

Transforms must be pure and deterministic. A transform that reads the clock, the locale or an environment variable gives different results on the two sides. Pass such values in as explicit parameters — a now argument, a locale argument — so both sides can supply the same value.

Where each kind of rule lives Three columns listing which rules belong in the shared schema, which are server-only and which are client-only. Shared schema • formats, lengths, required fields • normalisation transforms • cross-field rules on submitted values ✓ one source of truth for messages Server only • uniqueness, rate limits • breach corpus, fraud signals • authorisation of the change ✗ never trust the client for these Client only • confirm-password match • image pixel dimensions • async hints and suggestions ✗ must not affect server acceptance
The shared module holds every rule that needs only the submitted values; anything needing a database or a UI lives on one side.

Accessibility Compliance Across the Network Boundary

The accessibility requirements for a server error are exactly those for a client error: WCAG 3.3.1 wants the field identified and the error described in text, 3.3.3 wants a suggestion for fixing it, and 4.1.3 Status Messages wants the result announced without moving focus unnecessarily. A shared schema helps here in a way that is easy to overlook: because the message strings are identical, the server error arrives in the same words a screen reader user may already have heard on blur, and in the same place (the field’s described-by container). Mixing client messages like “Enter a valid email” with server messages like "email: Invalid format" forces users to interpret two vocabularies for the same problem.

Render server errors by calling setCustomValidity() and reportValidity(), exactly as the client path does, so focus moves to the first failing field and the message is announced. For errors that are not about a single field — “this account was created in the meantime” — use a form-level alert region and an accessible error summary at the top of the form.

Common Gotchas and Debugging

Importing server code into the shared module. One import { db } from "../db" in a refinement drags the database client into the browser bundle, or fails the build.

// Before: server dependency inside the shared schema
email: z.string().email().refine(async (v) => !(await db.users.exists(v)), "Taken"),
// After: shared schema stays pure; the server adds the uniqueness check after parsing
email: z.string().email(MESSAGES.emailInvalid),

Checkboxes and numbers from FormData. A checked box submits "on"; an unchecked one submits nothing. Numbers arrive as strings. Model the wire format (z.literal("on"), z.coerce.number()), not the JavaScript type you wish you had.

Different Zod versions on each side. A monorepo with two lockfile entries for Zod can produce subtly different messages or behaviour. Pin one version at the workspace root and check it in CI.

Messages hard-coded in two places. If the client renders its own copy for some errors and the schema’s for others, they drift. Keep every message in the shared MESSAGES object, and localise from there, as described in keeping client and server error messages in sync.

Unknown keys rejected by .strict(). A server schema with .strict() rejects a new field that the client started sending before the server deployed. Use the default strip behaviour for form input, and version the schema deliberately.

Versioning Shared Schemas Across Deployments

The client bundle and the server rarely deploy at exactly the same instant, and users keep old tabs open for days. A shared schema therefore exists in at least two versions in production at any moment, and changes must be safe in both directions. The rule of thumb is that the server must accept anything the previous client version could send, and a client must be able to render anything the server can return. In practice: loosen on the server first and tighten on the client first. To raise a minimum password length, deploy the client change (so new users see the new rule), then the server change after old tabs have expired. To add a new required field, deploy the server accepting it as optional, then the client sending it, then the server requiring it.

Rolling out a stricter rule safely A timeline of deployments showing a stricter password rule rolled out to the client first, old tabs expiring, and the server tightened last. Client rule min 12 shown to new users Old tabs still send min 8 values Server rule accepts min 8 requires min 12 0 2 4 6 8 10 12 14 days
Tighten the client first and the server last, so no open tab ever sends data the server has started rejecting.

Publishing the shared package with a semantic version and a changelog makes these rollouts reviewable. A breaking change to a message key or a field name is a major version; the server can then support two major versions of the schema for a transition period, choosing by a version field the client sends with each submission.

Keeping Shared Schemas Off the Critical Path

A schema library adds weight to the client bundle, and a shared validation package tends to grow until it includes every form in the product. Two habits keep the cost proportional. First, export one schema per module (@acme/validation/signup, @acme/validation/checkout) rather than a barrel file that re-exports everything, so each page imports only the rules it uses and tree-shaking has nothing to fight. Second, remember that the native layer works before any script loads: required, minlength, type="email" and friends catch the most common mistakes even if the schema bundle is still downloading. That makes it safe to load the schema with the form’s enhancement script rather than in the critical path, and on slow connections the user still gets instant, native feedback while the richer rules arrive.

If bundle size is a hard constraint, a smaller schema library with the same shape can replace Zod on the client while the server keeps using it — as long as both are driven from the same rule definitions. The trade-offs between libraries, and the Standard Schema interface that lets form code stay library-agnostic, are covered in the alternative schema libraries topic.

When Not to Share a Schema

Sharing is the default, not a law. Some forms are better served by separate, deliberately different rule sets. Internal admin tools that trusted staff use may skip client validation almost entirely and rely on the server’s messages. Public APIs consumed by third parties often need stricter server validation than any single client form — rejecting unknown keys, enforcing exact types — because they have no UI to explain errors. And some client-side checks are experience features rather than rules: a strength meter, a typo suggestion, a live character counter. None of those belong in the shared module. The test for whether a rule should be shared is simple: if the server would reject a request that breaks it, it must be shared; if not, it lives on the client alone, and the server must never depend on it.

Testing Shared Schemas Once, Trusting Them Twice

Because the same module runs on both sides, one thorough unit suite covers both — but only if the tests exercise the wire format, not a convenient JavaScript object. Build test inputs with FormData, exactly as the browser and server receive them, and run them through Object.fromEntries before parsing. Then add one contract test per route: post a known-invalid FormData to the real server handler and assert that the response’s errors object equals what safeParse(...).error.flatten().fieldErrors returns in the test. That single assertion catches most drift, from a server that re-implemented a rule by hand to a missing .trim() on one side.

import { describe, it, expect } from "vitest";
import { signupSchema } from "@acme/validation/signup";
import { POST } from "../apps/api/src/routes/signup";

const fd = (entries: Record<string, string>) => {
  const f = new FormData();
  for (const [k, v] of Object.entries(entries)) f.append(k, v);
  return f;
};

describe("signup contract", () => {
  it("server errors equal shared-schema errors", async () => {
    const body = fd({ email: "  ADA@EXAMPLE ", password: "short" });
    const expected = signupSchema.safeParse(Object.fromEntries(body));
    const res = await POST(new Request("http://test/api/signup", { method: "POST", body }));
    expect(res.status).toBe(422);
    expect((await res.json()).errors).toEqual(!expected.success && expected.error.flatten().fieldErrors);
  });
});

Browser Compatibility Matrix

Runtime support for shared schema code A table showing which runtimes support the features shared schemas rely on. Browsers Node Edge runtimes Zod core ✓ Yes ✓ Yes ✓ Yes FormData parsing ✓ Yes ✓ 18+ ✓ Yes String.normalize ✓ Yes ✓ Yes ✓ Yes Intl.Segmenter ✓ Yes ✓ 16+ varies
Everything a pure schema module needs is available in browsers and all common server runtimes; the constraint is discipline about imports, not compatibility.
Feature Browsers Node Deno / Bun / Edge Notes
ES modules in shared package Via bundler 18+ Yes Ship ESM; avoid CommonJS-only helpers
request.formData() 18+ Yes Server side of progressive enhancement
structuredClone of parsed data Yes 17+ Yes Safe copies of parsed DTOs
Top-level await in schema module Avoid Avoid Avoid Keeps the module synchronous and importable everywhere

Frequently Asked Questions

Why share validation schemas between client and server?

So the rules, normalisation and messages are identical in both places. Separate copies drift, producing server rejections the user cannot understand or fix because the form said everything was valid.

Can a shared schema include database checks like uniqueness?

No. Keep the shared module free of server dependencies. Run uniqueness and other data-dependent checks on the server after the shared schema passes, and return their errors in the same field-keyed shape.

How do I show server-side schema errors in the form?

Return error.flatten().fieldErrors with a 422 status, then apply each message with setCustomValidity() on the matching input and call reportValidity(), exactly as for client-side errors.

How should shared schema changes be deployed?

Loosen rules on the server first and tighten them on the client first, so open tabs running the old client never send data the new server rejects. Version the shared package and note breaking changes.

← Back to Server and Full-Stack Validation

Explore This Section