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.
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.
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.
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.
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
| 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.
Related Guides
- Sharing Zod Schemas in a Monorepo — package layout, exports and lint boundaries.
- Validating FormData on the Server with Zod — parsing real form submissions.
- Keeping Client and Server Error Messages in Sync — message keys, localisation and drift tests.
- Using Zod for Complex Form Schemas — the client-side schema patterns this builds on.
← Back to Server and Full-Stack Validation