Sharing Types and Validations with Zod Across a Monorepo
James Reed
Infrastructure Engineer · Leapcell

Bridging Frontend and Backend with Shared Schemas
Building modern full-stack applications often involves a frontend framework like Next.js and a backend framework such as Fastify. A common challenge in this setup is maintaining consistency between the data structures and validation rules used by both ends of the application. Without a unified approach, developers frequently find themselves duplicating type definitions and validation logic, leading to inconsistencies, increased maintenance overhead, and a higher risk of bugs. This problem becomes even more pronounced in a monorepo, where multiple packages collaborate, making a shared source of truth for interfaces and data validation critical.
This is precisely where tools like Zod shine. Zod is a TypeScript-first schema declaration and validation library that can infer static types, making it an ideal candidate for defining shared data contracts. By centralizing our schema definitions in a monorepo workspace, we can ensure that both our Next.js frontend and Fastify backend operate on the same understanding of data, from API request bodies to database models. In this article, we'll delve into the practical implementation of using Zod within a monorepo to achieve seamless type and validation sharing, significantly improving developer experience and application robustness.
Understanding the Building Blocks
Before diving into the implementation, let's clarify some core concepts crucial to our discussion:
- Monorepo: A monorepo is a single repository containing multiple, distinct projects or "packages" that are often related. Tools like Yarn Workspaces or Lerna are commonly used to manage dependencies and build processes within a monorepo. This structure facilitates code sharing and consistency across different parts of an application ecosystem.
- Next.js: A popular React framework for building server-rendered, static, and client-side web applications. It's often used for the frontend of full-stack applications.
- Fastify: A highly performant and developer-friendly web framework for Node.js, commonly used for building backend APIs.
- Zod: A TypeScript-first schema declaration and validation library. It allows you to define schemas for various data types (objects, strings, numbers, etc.) and then validate data against these schemas. A key feature of Zod is its ability to infer TypeScript types directly from the schema definitions, eliminating the need for manual type declaration.
The Problem of Duplication
Consider a simple application that allows users to create a new post. Without shared schemas, you might define the Post interface and its validation rules independently in both the frontend and backend:
Frontend (Next.js):
// pages/posts/new.tsx interface CreatePostRequest { title: string; content: string; tags?: string[]; } // Client-side validation logic...
Backend (Fastify):
// src/routes/posts.ts interface CreatePostInput { title: string; content: string; tags?: string[]; } // Joi, Yup, or manual validation logic...
This duplication is fragile. If you add a new field like authorId or change a validation rule (e.g., title must be at least 5 characters), you have to remember to update both places. This is a common source of bugs.
The Monorepo and Zod Solution
Our approach involves creating a shared package within our monorepo dedicated to defining Zod schemas. Both the Next.js frontend and Fastify backend will depend on this shared package, ensuring they use the exact same data contracts.
Let's set up a hypothetical monorepo structure:
my-monorepo/
├── packages/
│ ├── api/ # Fastify backend
│ ├── web/ # Next.js frontend
│ └── common/ # Shared Zod schemas and types
├── package.json
├── yarn.lock
└── tsconfig.json
1. Define Schemas in the common package:
First, install Zod in your common package: yarn add zod.
Now, create a file (e.g., schemas.ts) in packages/common/src to define your Zod schemas:
// packages/common/src/schemas.ts import { z } from 'zod'; export const createPostSchema = z.object({ title: z.string().min(5, { message: "Title must be at least 5 characters long" }), content: z.string().min(10, { message: "Content must be at least 10 characters long" }), tags: z.array(z.string()).optional(), authorId: z.string().uuid("Invalid author ID format").optional(), // Example of a new field }); // Infer TypeScript type directly from the schema export type CreatePostInput = z.infer<typeof createPostSchema>;
2. Use Schemas in the Fastify Backend:
Install the @fastify/zod plugin for Fastify, which integrates Zod for request body, querystring, and params validation. In your api package, install Zod and @fastify/zod: yarn add zod @fastify/zod.
Then, use the schema from your common package in your Fastify routes:
// packages/api/src/routes/posts.ts import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { createPostSchema, CreatePostInput } from '@my-monorepo/common/schemas'; // Import from common package export default async function (fastify: FastifyInstance) { fastify.post( '/posts', { schema: { body: createPostSchema, // Use Zod schema for validation }, }, async (request: FastifyRequest<{ Body: CreatePostInput }>, reply: FastifyReply) => { const { title, content, tags, authorId } = request.body; // The request.body type is now automatically inferred thanks to `CreatePostInput` // and Fastify's schema validation, ensuring data is valid before reaching here. // Simulate saving the post console.log('Received valid post:', { title, content, tags, authorId }); return reply.status(201).send({ message: 'Post created successfully', data: { title, content, tags, authorId } }); } ); }
Explanation:
- We import
createPostSchemadirectly from thecommonpackage. @fastify/zodautomatically handles the validation whencreatePostSchemais passed toschema.body. If the incoming request body doesn't conform to the schema, Fastify will automatically respond with a 400 Bad Request error.- The
CreatePostInputtype ensures that therequest.bodyin our handler is correctly typed, eliminating runtime type errors for valid data.
3. Use Schemas in the Next.js Frontend:
In your web package, install Zod: yarn add zod. Then, consume the schema from the common package for client-side forms and API calls.
// packages/web/pages/create-post.tsx import React, { useState } from 'react'; import { createPostSchema, CreatePostInput } from '@my-monorepo/common/schemas'; // Import from common package const CreatePostPage: React.FC = () => { const [formData, setFormData] = useState<CreatePostInput>({ title: '', content: '', tags: [], }); const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<string | null>(null); const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); // Clear error for the field as user types if (errors[name]) { setErrors((prev) => ({ ...prev, [name]: undefined })); } }; const handleCreatePost = async (e: React.FormEvent) => { e.preventDefault(); setMessage(null); setErrors({}); setLoading(true); // Client-side validation using the shared Zod schema const validationResult = createPostSchema.safeParse(formData); if (!validationResult.success) { const fieldErrors: Record<string, string | undefined> = {}; validationResult.error.errors.forEach((err) => { if (err.path.length > 0) { fieldErrors[err.path[0]] = err.message; } }); setErrors(fieldErrors); setLoading(false); return; } try { const response = await fetch('http://localhost:3001/posts', { // Assuming Fastify runs on 3001 method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(validationResult.data), // Send validated data }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to create post'); } setMessage('Post created successfully!'); setFormData({ title: '', content: '', tags: [] }); // Clear form } catch (error: any) { setMessage(`Error: ${error.message}`); } finally { setLoading(false); } }; return ( <div> <h1>Create New Post</h1> <form onSubmit={handleCreatePost}> <div> <label htmlFor="title">Title:</label> <input type="text" id="title" name="title" value={formData.title} onChange={handleChange} /> {errors.title && <p style={{ color: 'red' }}>{errors.title}</p>} </div> <div> <label htmlFor="content">Content:</label> <textarea id="content" name="content" value={formData.content} onChange={handleChange} /> {errors.content && <p style={{ color: 'red' }}>{errors.content}</p>} </div> <div> <label htmlFor="tags">Tags (comma-separated):</label> <input type="text" id="tags" name="tags" value={formData.tags?.join(',') || ''} onChange={(e) => { const value = e.target.value.split(',').map(tag => tag.trim()).filter(Boolean); setFormData((prev) => ({ ...prev, tags: value })); if (errors.tags) { setErrors((prev) => ({ ...prev, tags: undefined })); } }} /> {errors.tags && <p style={{ color: 'red' }}>{errors.tags}</p>} </div> <button type="submit" disabled={loading}> {loading ? 'Submitting...' : 'Create Post'} </button> </form> {message && <p>{message}</p>} </div> ); }; export default CreatePostPage;
Explanation:
- Again,
createPostSchemaandCreatePostInputare imported from thecommonpackage. - The
CreatePostInputtype automatically provides type-safety for ourformDatastate. - Client-side validation is performed using
createPostSchema.safeParse(formData). This ensures that the data sent to the backend already conforms to the defined schema, providing a better user experience by catching errors locally without a server round trip. - The inferred types from Zod dramatically reduce the chance of sending malformed data to the backend or misinterpreting data received from it.
Application Benefits
By centralizing types and validations in a common package using Zod:
- Single Source of Truth: All data contracts are defined in one place, reducing redundancy and ensuring consistency.
- Type Safety Everywhere: Zod's
z.inferautomatically provides TypeScript types for both frontend and backend, enhancing autocompletion and error checking during development. - Reduced Duplication: Validation logic doesn't need to be rewritten for each environment.
- Improved Developer Experience: Developers can easily see required fields, types, and validation rules by looking at the shared schema.
- Enhanced Robustness: Inconsistent data structures or validation rules, a common source of bugs, are virtually eliminated.
- Easier Refactoring: Changes to data structures only require updating the schema in
common, and TypeScript will help highlight affected areas in both the frontend and backend. - Client-side Validation Advantage: Frontend can perform validation against the same rules as the backend, offering immediate feedback to users and reducing unnecessary network requests for invalid data.
Conclusion
Sharing types and validations across a monorepo is a best practice that significantly enhances the development and maintenance of full-stack applications. By leveraging Zod in a dedicated common package, we establish a robust single source of truth for our data contracts, providing invaluable type safety and consistent validation logic from Next.js frontend forms to Fastify backend API endpoints. This approach not only streamlines development workflows but also leads to more reliable and maintainable software, ultimately building a stronger bridge between your application's layers.