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 safeParse on 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.

One schema module, three consumers The shared schema module contains shapes and per-field rules with no browser or server dependencies. The browser form imports it for validation on blur and submit. The API handler imports it to parse request bodies and then applies server-only checks such as uniqueness and permissions. TypeScript types inferred from the schema are used by both the form state and the API client, so a field renamed in one place fails to compile everywhere it is used. Browser form safeParse on blur/submit. Instant feedback. Shared module Shapes + per-field rules. No DOM, no DB, no secrets. Inferred types. API handler safeParse the body. Then server-only rules.

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

  1. Create a dependency-free schema module. Only the validation library is imported. Anything touching the database, the DOM or secrets stays out.
  2. 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.
  3. 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.
  4. Layer server-only rules after parsing. Uniqueness, ownership, quotas and rate limits require server state, and their messages use the same issue format.
  5. 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.
  6. 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.

Where each rule lives Required fields, length limits, formats and enums belong in the shared schema because they depend only on the value. Uniqueness, ownership, permissions and quotas belong only on the server because they depend on stored data. Availability hints while typing, typo suggestions and unsaved-change warnings belong only on the client because they are user-experience helpers. Rule Lives in Why required, length, format, enum shared depends only on the value uniqueness, ownership, quotas server only needs stored data and auth availability hints, typo suggestions client only UX helpers; server re-checks

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.

A rule change deployed server-first The server deploys schema version three, lowering the display name limit from sixty to fifty characters. An old browser tab still runs version two and accepts a 55 character name locally. The server parses the body with version three, rejects it with a 422 issue on the displayName path and schema version three. The client places the message on the display name field; because the response's schema version is newer, it also suggests reloading to get the latest form. Old client (v2) Server (v3) PUT displayName (55 chars) — passed v2 rules 422 { path: displayName, schemaVersion: 3 } client: show on field + suggest reload

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.


Related

← Server Error Reconciliation