When the browser and the API each have their own copy of the validation rules, they drift: the client allows a 60-character display name, the server caps it at 50, and users get past every client check only to see “Something went wrong” — or worse, the server accepts data the client would have rejected and the UI cannot display it.
A shared schema module fixes the drift by construction: the same rules run in the form for instant feedback and in the API for authority, and the server’s issues come back in a shape the client already understands. This page, part of server error reconciliation, covers how to structure the shared module, what must stay server-only, and how to handle client and server deploying at different times.
Context and prerequisites
What “sharing” means in practice:
- One module exports the schemas (
src/shared/schemas/profile.ts) with no imports from browser-only or server-only code — no DOM, no database clients, no environment secrets. - The client imports it for form validation, typically via a resolver or
safeParseon blur and submit. - The server imports it to parse request bodies, then applies server-only rules (uniqueness, permissions, rate limits) on top.
- Issues travel back in a stable format — path and code, optionally message — so the client can place them on fields without guessing, as in mapping 422 responses to field errors.
Monorepos make this easy (a workspace package); separate repos can publish a small versioned package.
The core pattern: a shared module, a server layer, a transport format
// packages/schemas/src/profile.ts — imported by BOTH sides
import { z } from "zod";
export const ProfileInput = z.object({
displayName: z.string().trim().min(2, "Use at least 2 characters.").max(50, "Use 50 characters or fewer."),
email: z.string().trim().toLowerCase().email("Enter an email like [email protected]."),
website: z.string().trim().url("Enter a full web address, like https://example.com.").optional().or(z.literal("")),
});
export type ProfileInput = z.infer<typeof ProfileInput>;
// A stable, serialisable issue shape for the wire. No ZodError across HTTP.
export interface WireIssue { path: string; code: string; message: string }
export const toWireIssues = (e: z.ZodError): WireIssue[] =>
e.issues.map((i) => ({ path: i.path.join("."), code: i.code, message: i.message }));
// Bump when a change would make old clients and new servers disagree.
export const PROFILE_SCHEMA_VERSION = 3;
// server: apps/api/src/routes/profile.ts
import { ProfileInput, toWireIssues, type WireIssue, PROFILE_SCHEMA_VERSION } from "@acme/schemas/profile";
export async function putProfile(req: Request, userId: string): Promise<Response> {
const body = await req.json().catch(() => null);
const parsed = ProfileInput.safeParse(body);
if (!parsed.success) return json(422, { issues: toWireIssues(parsed.error), schemaVersion: PROFILE_SCHEMA_VERSION });
// Server-only rules live HERE, never in the shared module.
const issues: WireIssue[] = [];
if (await emailUsedByAnother(parsed.data.email, userId)) {
issues.push({ path: "email", code: "email_taken", message: "That email is used by another account." });
}
if (issues.length) return json(422, { issues, schemaVersion: PROFILE_SCHEMA_VERSION });
await saveProfile(userId, parsed.data);
return json(200, { profile: parsed.data });
}
const json = (status: number, data: unknown) =>
new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
declare function emailUsedByAnother(e: string, id: string): Promise<boolean>;
declare function saveProfile(id: string, d: unknown): Promise<void>;
// client: apps/web/src/profile/submit.ts
import { ProfileInput, type WireIssue } from "@acme/schemas/profile";
export async function submitProfile(values: unknown, setFieldError: (path: string, msg: string) => void) {
const local = ProfileInput.safeParse(values); // same rules, instant
if (!local.success) { local.error.issues.forEach((i) => setFieldError(i.path.join("."), i.message)); return; }
const res = await fetch("/api/profile", { method: "PUT", body: JSON.stringify(local.data) });
if (res.status === 422) {
const { issues } = (await res.json()) as { issues: WireIssue[] };
issues.forEach((i) => setFieldError(i.path, i.message)); // same paths, same field names
}
}
Step-by-step walkthrough
- Create a dependency-free schema module. Only the validation library is imported. Anything touching the database, the DOM or secrets stays out.
- Export inferred types alongside schemas. Form state, API clients and handlers use
z.infer, so a renamed field breaks the build everywhere it is used. - Parse request bodies with the shared schema on the server. The server’s first check is identical to the client’s last check; divergence becomes impossible for those rules.
- Layer server-only rules after parsing. Uniqueness, ownership, quotas and rate limits require server state, and their messages use the same issue format.
- Send issues in a wire format, not a
ZodError. Path as a dotted string, a code, and a message. The client places them by path without knowing whether the schema or the database produced them. - Version the schema when deployments can skew. Include a schema version in error responses; if the client sees a newer version, it can prompt a reload rather than rendering issues for fields it does not know.
Why server-only rules must stay out of the shared module
It is tempting to put “email must be unique” in the shared schema as an async refinement that calls an API. On the server that refinement would call itself over HTTP, or need a second implementation; on the client it would run network checks during every local validation, including on each keystroke if validation is live. It also drags server concerns — endpoints, auth headers — into a module that should be pure. Keep the shared module to rules that can be decided from the value alone, and let each side add the context-dependent rules it is able to evaluate: the client adds debounced availability hints, the server adds the authoritative check.
Failure modes and edge cases
1. Bundle size
A shared schema module pulls the validation library into the client bundle. Zod is moderately sized; if bundle size is critical, consider a smaller library, per Zod vs Yup vs Valibot bundle size and performance, or code-split the form.
2. Transforms that differ by side
.transform() in a shared schema runs on both sides. A transform that formats a phone number for display is harmless; one that hashes a password or reads the clock produces different results. Keep transforms deterministic and side-agnostic.
3. Deploy skew
If the server deploys a stricter rule before clients reload, users with old tabs pass client validation and fail server validation — still correctly, because the server is authoritative, and the issue maps to the right field. The reverse (a looser server) is harmless. Skew only becomes a problem when a field is renamed; handle that with versioned endpoints or a reload prompt.
4. Coercion differences
The client parses strings from inputs; the server parses JSON with real numbers. If the shared schema coerces, the server becomes lenient too. Keep coercion in a client-side form layer, as in coercing form strings with Zod preprocess and coerce.
5. Messages and locale
Shared schemas with English messages give server responses in English regardless of the user. Either translate on the client from codes, or apply a locale-aware error map on the server, per custom Zod error maps and localised messages.
Verification checklist
Frequently Asked Questions
Can I share schemas between a TypeScript frontend and a non-JavaScript backend?
Not directly. Generate a JSON Schema from the Zod schema (with a converter) and validate with a JSON Schema library on the backend, or the reverse — author JSON Schema and validate it in the browser, as in JSON Schema validation in the browser with Ajv.
Should the server trust that the client already validated?
Never. The client’s validation is for the user’s benefit. Anyone can send requests without the client, so the server parses and checks everything as if no client-side validation existed.
How do tRPC or server actions change this?
They make sharing implicit: the procedure’s input schema is imported by the client for types and can be used for form validation directly. The same rules apply — keep server-only checks inside the procedure and return issues in a shape the form maps back.