Every multi-package monorepo hits the same wall: your web app sends data to the API; the API validates it, transforms it, stores it; the admin dashboard reads the same data and needs to know its shape. Define the type three times (once in TypeScript, once in API validation, once in the admin UI)? Congratulations, you've accidentally made a lying codebase. You change the User shape in the database, forget to update the web app's type definition, and suddenly the form silently accepts invalid data. Six months later, your CEO's dashboard shows corrupted records and you're debugging in production at 2 AM. Aidxn's pattern for multi-tenant platforms and Velocity templates: **put Zod schemas in a shared @packages/schemas package, infer types from the schemas with z.infer<typeof schema>, and validate everywhere.** The schema is the single source of truth. Types are computed from it. Validation happens on both client and server. No lying types, no sync friction.
The Three Approaches (and Why Zod Wins)
Approach 1: Bare TypeScript Types (The Lie)
Define types in TypeScript, share them between packages, trust that validation happens somewhere:
// packages/types/user.ts
export type User = {
id: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
};
Web app imports it, API imports it, both "know" the shape. But where's the validation? The API handler takes a JSON body and casts it to User without checking. The web form accepts anything that vaguely looks like a user. Runtime data mismatches silently. This approach scales until it doesn't — usually around your third breaking API change.
Approach 2: OpenAPI Code Generation (Bureaucratic)
Write an OpenAPI/Swagger spec, generate types in TypeScript, generate API stubs in multiple languages, distribute the generated code. Solves the sync problem (generated types match the spec). But now you have a build step, a spec file (YAML/JSON), and generated code that nobody edits. It's correct but heavyweight. When you add a new field to a User, you update the spec, regenerate, push the updated types to npm, and all consumers pull the new version. Fine for 3–5 stable services. Painful if you iterate fast.
Approach 3: Zod Schemas in Shared Package (The Winner)
Define schemas once in Zod, share them, infer types, validate everywhere:
// packages/schemas/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.date(),
});
export type User = z.infer
Web app imports UserSchema, uses it for form validation and TypeScript types. API uses the same schema to parse and validate request bodies. Admin dashboard uses the same schema to check if data is safe to render. One schema, three packages, zero lies. Add a field? Update the schema once. All packages see the change the next install. Validation runs consistently everywhere.
Building the Shared Schemas Package
Folder structure:
packages/schemas/
├── package.json
├── src/
│ ├── index.ts
│ ├── user.ts
│ ├── order.ts
│ ├── product.ts
│ └── errors.ts
└── dist/ (generated)
Minimal package.json for packages/schemas:
{
"name": "@myapp/schemas",
"version": "0.0.1",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"devDependencies": {
"typescript": "^5.3.0",
"zod": "^3.22.0"
},
"dependencies": {
"zod": "^3.22.0"
}
}
Each schema file exports a Zod schema and its inferred type:
// packages/schemas/src/user.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email().max(255),
role: z.enum(['admin', 'editor', 'viewer']),
status: z.enum(['active', 'suspended', 'deleted']).default('active'),
createdAt: z.date(),
updatedAt: z.date(),
});
export const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
status: true,
});
export const UpdateUserSchema = CreateUserSchema.partial();
export type User = z.infer
Root packages/schemas/src/index.ts exports everything:
export * from './user';
export * from './order';
export * from './product';
export * from './errors';
Build once with tsc, and every package imports from @myapp/schemas.
Using Schemas in Three Places
Web App (React) — Form Validation
import { CreateUserSchema, type CreateUser } from '@myapp/schemas';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function CreateUserForm() {
const { register, handleSubmit, formState: { errors } } = useForm
API (Node / Edge Function) — Request Parsing
import { CreateUserSchema } from '@myapp/schemas';
export async function POST(req: Request) {
const body = await req.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
const user = result.data; // TypeScript knows this is CreateUser
// ... save to database ...
return Response.json(user, { status: 201 });
}
Admin Dashboard (React / Next.js) — Safe Display
Role: {validated.data.role}import { UserSchema, type User } from '@myapp/schemas';
async function UserDetailPage({ userId }: { userId: string }) {
const user = await fetch(/api/users/${userId}).then(r => r.json());
// Validate shape matches schema (catch API corruption)
const validated = UserSchema.safeParse(user);
if (!validated.success) {
throw new Error('Server returned corrupted user data');
}
return (
{validated.data.email}
Six FAQs
Does Zod validation slow down my API?
Negligibly. Zod parses JSON in microseconds. A modest Zod validation adds <1ms per request. If your API is hitting parse-time limits, the real bottleneck is I/O (database, network). Validation is not the problem.
What if I don't need validation on the client (form inputs already constrained)?
Still use Zod. You benefit from type inference and centralized definitions. But you can skip calling zodResolver on React Hook Form and just use the types. Validation still happens server-side, which is the critical layer.
How do I version schemas across packages?
Bump @myapp/schemas version in monorepo package.json. All other packages see the update on install. If breaking changes are unavoidable, create a new schema (e.g. UserV2Schema) and handle migrations in the API. Keep old schemas in the package for a transition period if needed.
Can I use schemas for database queries (Prisma, Drizzle)?
You can reference schemas in your ORM types, but ORMs generate their own types. Keep them separate — let Prisma own the database shape, Zod own the API contract shape. If they differ, that's intentional (you might expose a subset of fields, or compute additional fields on the API layer).
What if I have conditional validation (e.g. admin sees more fields than a regular user)?
Create multiple schemas and infer multiple types:
export const UserPublicSchema = UserSchema.omit({ createdAt: true });
export type UserPublic = z.infer
Use the appropriate schema based on the caller's role. Return UserPublicSchema to regular users, UserAdminSchema to admins.
Does this work with generated clients (like OpenAPI codegen)?
Yes. Run both: keep your Zod schemas as the single source of truth, and optionally generate OpenAPI spec from them (via zod-to-openapi library). You get validation everywhere and machine-generated API docs. Best of both worlds.
The Bottom Line
The moment you have a web app + API in the same monorepo, you need type sharing. Zod schemas in a shared package is the pattern: they're fast, readable, validate on every layer, and keep your types honest. Add a field to a User? One edit. All three packages see it instantly. No regeneration, no sync friction, no lying types at 2 AM. For Aidxn's multi-tenant SaaS and Velocity templates, it's non-negotiable. Start with this pattern early — retrofitting it into a sprawling codebase is painful.
Building a multi-package monorepo? Read our breakdown on pnpm workspaces + Turborepo to keep your builds fast and lean, or book an architecture consultation to structure type sharing and API validation for scale.