Sharing Zod Schemas in a Monorepo

How do you set up a monorepo so that a web app and an API import the same validation schemas — with one Zod version, no server code leaking into the browser bundle, per-form entry points that tree-shake, and TypeScript types that flow to both sides? This recipe creates a @acme/validation workspace package with explicit exports, pins Zod at the root, enforces an import boundary with ESLint, and consumes the package from a browser form that still reports through setCustomValidity() and the Constraint Validation API, and from an API route that returns the same messages.

When to Use a Shared Validation Package

A dedicated package is worth the setup as soon as a second consumer appears: a web app and an API, a web app and a mobile app, or two front-ends for one back-end. It fits when:

  • Client and server are in one repository — or can import a published package — and both are TypeScript or JavaScript.
  • Forms change often, so keeping two copies in sync by hand is a recurring source of bugs.
  • Several teams contribute rules, and a package with its own tests and changelog makes changes reviewable.

If the server is written in another language, sharing code is not possible; share a JSON Schema generated from your Zod schemas instead, or keep the rules in a language-neutral format and generate both sides. The broader reasoning for sharing is in the shared client–server schemas topic.

Monorepo layout for shared validation A layered view of the repository: the root pins Zod, the validation package exposes per-form entry points, and the web app and API consume those entry points. Root package.json workspaces, single pinned zod, shared lint config packages/validation signup.ts, checkout.ts, messages.ts — exports map apps/web imports @acme/validation/signup for the form apps/api imports @acme/validation/signup for the route
Rules live in one package with no platform imports; each app imports only the per-form entry points it needs.

Minimal Working Monorepo Setup

// package.json (root)
{
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "devDependencies": { "typescript": "^5.6.0", "eslint": "^9.0.0" },
  "overrides": { "zod": "3.23.8" }
}
// packages/validation/package.json
{
  "name": "@acme/validation",
  "version": "1.4.0",
  "type": "module",
  "sideEffects": false,
  "exports": {
    "./signup": { "types": "./dist/signup.d.ts", "import": "./dist/signup.js" },
    "./checkout": { "types": "./dist/checkout.d.ts", "import": "./dist/checkout.js" },
    "./messages": { "types": "./dist/messages.d.ts", "import": "./dist/messages.js" }
  },
  "peerDependencies": { "zod": "^3.23.0" },
  "scripts": { "build": "tsc -p tsconfig.build.json", "test": "vitest run" }
}
// packages/validation/src/signup.ts
import { z } from "zod";
import { M } from "./messages";

export const signupSchema = z.object({
  email: z.string().trim().min(1, M.email.required).email(M.email.invalid),
  password: z.string().min(12, M.password.short).max(128, M.password.long),
  terms: z.literal("on", { errorMap: () => ({ message: M.terms.required }) }),
});

export type SignupInput = z.input<typeof signupSchema>;
export type SignupData = z.output<typeof signupSchema>;
// eslint.config.js (root) — the import boundary
export default [
  {
    files: ["packages/validation/src/**/*.ts"],
    rules: {
      "no-restricted-globals": ["error", "window", "document", "localStorage", "process"],
      "no-restricted-imports": ["error", {
        patterns: [
          { group: ["node:*", "fs", "path", "crypto"], message: "Shared schemas must run in the browser." },
          { group: ["react", "vue", "@angular/*", "next/*"], message: "Shared schemas must be framework-free." },
          { group: ["**/db", "**/server/**", "@prisma/*"], message: "Server code stays out of shared schemas." },
        ],
      }],
    },
  },
];

Three settings do the important work. exports gives each form its own entry point, so importing @acme/validation/signup never pulls in the checkout rules. sideEffects: false tells bundlers the package can be tree-shaken freely. And Zod is a peer dependency pinned once at the root through overrides, so the web app and API resolve exactly the same copy — two copies produce subtly different messages and break instanceof ZodError checks across the boundary.

// apps/web/src/signup-form.ts — the browser consumer
import { signupSchema } from "@acme/validation/signup";

const form = document.querySelector<HTMLFormElement>("#signup")!;
form.addEventListener("submit", (event) => {
  const result = signupSchema.safeParse(Object.fromEntries(new FormData(form)));
  const errors = result.success ? {} : result.error.flatten().fieldErrors;
  for (const el of form.querySelectorAll<HTMLInputElement>("input[name]")) {
    el.setCustomValidity(errors[el.name as keyof typeof errors]?.[0] ?? "");
  }
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});
// apps/api/src/routes/signup.ts — the server consumer
import { signupSchema, type SignupData } from "@acme/validation/signup";

export async function POST(req: Request) {
  const parsed = signupSchema.safeParse(Object.fromEntries(await req.formData()));
  if (!parsed.success) return Response.json({ errors: parsed.error.flatten().fieldErrors }, { status: 422 });
  const data: SignupData = parsed.data;
  return createAccount(data);
}
How a rule change reaches both apps A rule edited in the validation package is tested, built once, and picked up by both the web app and the API on their next build, with CI checking both consumers. Edit rule packages/ validation/src/ signup.ts Package tests vitest fixtures + contract Build once tsc → dist with d.ts Consumers apps/web and apps/api rebuild CI gate both apps type-check and test
One pull request changes the rule for both runtimes, and CI builds and tests both consumers before it merges.

Package Option Reference

Option Where Value Purpose
workspaces root package.json apps/*, packages/* Links the package into both apps
overrides / resolutions root exact Zod version One copy of Zod everywhere
peerDependencies.zod validation package ^3.23.0 Consumers provide Zod; no duplicate copies
exports validation package one entry per form Per-form imports and tree-shaking
sideEffects validation package false Allows bundlers to drop unused modules
no-restricted-imports ESLint node, frameworks, server paths Keeps the package runnable in any runtime
types condition exports entries .d.ts path Types resolve without path aliases

TypeScript’s moduleResolution must be bundler or node16/nodenext in both apps for the exports map to be honoured. With the older node resolution, imports of @acme/validation/signup fail to find types even though the runtime import works — the single most common setup error.

Verification Steps

// packages/validation/test/signup.contract.test.ts
import { describe, it, expect } from "vitest";
import { signupSchema } from "../src/signup";
import { POST } from "../../../apps/api/src/routes/signup";

describe("signup schema is the API's schema", () => {
  it("returns identical field errors", async () => {
    const body = new FormData();
    body.set("email", "not-an-email");
    body.set("password", "short");
    const expected = signupSchema.safeParse(Object.fromEntries(body));
    const res = await POST(new Request("http://test/signup", { method: "POST", body }));
    expect((await res.json()).errors).toEqual(!expected.success && expected.error.flatten().fieldErrors);
  });
});

Edge Cases and Failure Modes

Two copies of Zod. A package that lists zod in dependencies instead of peerDependencies can install its own copy under node_modules/@acme/validation/node_modules/zod. Errors created by one copy fail instanceof ZodError in code using the other, and message formats may differ. npm ls zod in CI catches it.

Source versus built output. Pointing exports at src/*.ts works in some bundlers and fails in Node. Either build the package (as above) or use a runtime that executes TypeScript directly in both apps; do not mix.

Circular imports between schemas. A checkout schema that imports the signup schema, which imports a shared address schema, which imports checkout constants, produces undefined at module evaluation. Keep leaf schemas (address, phone, money) in their own files with no upward imports.

Server-only rules sneaking in. Someone adds a Prisma call to a refinement “just for the server”. The lint rule stops it; the pattern to follow instead is to extend the shared schema in the API with .superRefine() or to run the check after parsing, as the validating FormData on the server recipe shows.

Extending Shared Schemas Per Runtime

The shared schema is the common core; each app extends it with rules only that app can run. On the client that might be a “confirm password” field the server never receives; on the server, a uniqueness check. Zod’s composition methods keep the extension explicit and the core untouched.

// apps/web: client-only extension
import { signupSchema } from "@acme/validation/signup";
export const signupFormSchema = signupSchema
  .extend({ confirm: z.string() })
  .superRefine((v, ctx) => {
    if (v.password !== v.confirm) ctx.addIssue({ code: "custom", path: ["confirm"], message: "Passwords don't match." });
  });

// apps/api: server-only extension after parsing
const parsed = signupSchema.safeParse(input);
if (parsed.success && (await users.exists(parsed.data.email))) {
  return Response.json({ errors: { email: ["An account with this email already exists."] } }, { status: 422 });
}

The confirmation rule is the one covered in cross-field password confirmation logic; here it simply lives in the web app’s extension rather than the shared core, because the server has no confirmation field to check.

Core schema versus runtime extensions Two columns showing what belongs in the shared core schema and what belongs in client or server extensions. Shared core • fields the server receives • formats, lengths, required • normalising transforms ✓ imported unchanged by every app Runtime extensions • client: confirm fields, UI-only rules • server: uniqueness, permissions • added with extend / superRefine ✗ never exported back into the core
Extensions add rules without editing the core, so the shared package stays free of UI fields and data access.

Publishing the Package Outside the Monorepo

When a separate repository — a mobile app, a partner integration — needs the same rules, publish the package to a private registry with semantic versioning. Treat message text changes as minor versions and field or rule changes as major versions, because a stricter rule can reject data an older client still sends. Keep a changelog that says, for each release, which forms changed and whether the change must be deployed client-first or server-first; the sequencing rule is explained in the topic’s section on versioning. Consumers can then upgrade deliberately, and a server can support two major versions during a transition by picking the schema based on a version field the client sends.

Frequently Asked Questions

Should Zod be a dependency or a peer dependency of the shared package?

A peer dependency, pinned once at the monorepo root. Otherwise each app may resolve a different copy, which breaks instanceof ZodError checks and can change messages.

How do I stop server code from leaking into the shared schemas?

Add an ESLint no-restricted-imports rule for the package that forbids Node built-ins, framework packages and server paths, and forbid browser and Node globals with no-restricted-globals.

Why do TypeScript types fail to resolve for my package subpaths?

The consuming app is probably using the old node module resolution, which ignores the exports map. Switch to bundler, node16 or nodenext resolution.

Where do client-only rules like confirm password go?

In a client extension of the shared schema created with .extend() and .superRefine() inside the web app, not in the shared core that the server imports.

← Back to Shared Client–Server Schemas