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.
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);
}
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.
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.
Related Guides
- Shared Client–Server Schemas — why and what to share.
- Keeping Client and Server Error Messages in Sync — the messages module in depth.
- Inferring TypeScript Types from Zod Schemas — the input and output types both apps use.
- Standard Schema: Library-Agnostic Validation — keeping consumers independent of the schema library.