Learn share types across Next.js & NestJS with Zod. share Zod schemas across Next.js and NestJS to keep types in sync, validate once, and ship faster. Typed End‑to‑End: sharing types between your Next.js frontend and NestJS backend.
…and finally ditching the class‑validator / Zod duplication
Building a TypeScript stack usually starts with a happy promise: “We’ll have types everywhere!” Then reality strikes—your NestJS backend wants classes decorated with class‑validator, while your Next.js frontend prefers schemas written in Zod.
Two copies of every DTO quickly appear, they drift apart, and bugs sneak in.
Below is a practical guide to one‑source‑of‑truth validation that keeps both sides in sync. We’ll:
1. Put all schemas in a tiny shared package.
2. Generate the types the frontend needs and the runtime validation the backend expects—from that same file.
3. Wire everything together with a couple of lightweight adapters (@nestjs/zod and react‑hook‑form).
Prerequisites
- Node ≥ 18, pnpm (or npm/yarn)
- A NestJS API and a Next.js app living in the same repo (monorepo or plain folders)
- Basic familiarity with Zod and NestJS pipes
Share Types Across Next.js & NestJS with Zod
1. Project layout
my‑workspace/
├─ apps/
│ ├─ api/ # NestJS project
│ └─ web/ # Next.js project
└─ packages/
└─ schema/ # <- our single source of truth
Use pnpm workspaces, npm workspaces, or Turborepo—the tooling doesn’t matter as long as apps/api
and apps/web
can import packages/schema.
2. Shared schema package (packages/schema)
What lives here?
Only pure TypeScript & Zod—no NestJS, no React, no node‑specific code. That keeps the package 100 % reusable.
// packages/schema/src/user.ts import { z } from 'zod'; /** * Zod schema describing the payload we expect when creating a user. * Validation happens at runtime, and z.infer gives us the TypeScript type. */ export const createUserSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().min(2), role: z.enum(['ADMIN', 'CUSTOMER']).default('CUSTOMER'), }); /** TypeScript type for free! */ export type CreateUserDto = z.infer<typeof createUserSchema>;
z.infer extracts the exact same static type the compiler will enforce in the frontend and backend.
Add an index.ts
that re‑exports every schema so consumers can write import { createUserSchema } from '@acme/schema'
.
3. Using the schema in NestJS (apps/api)
Goal: Accept requests whose body matches createUserSchema. If the data is invalid, reply with 400—without rewriting the schema.
Install the adapter:
pnpm add @nestjs/zod zod
Create a reusable validation pipe:
// apps/api/src/common/pipes/zod-validation.pipe.ts import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform, } from '@nestjs/common'; import { ZodSchema } from 'zod'; @Injectable() export class ZodValidationPipe implements PipeTransform { constructor(private schema: ZodSchema) {} transform(value: unknown, _metadata: ArgumentMetadata) { const result = this.schema.safeParse(value); if (!result.success) { throw new BadRequestException(result.error.flatten()); } return result.data; } }
Use it in a controller:
// apps/api/src/users/users.controller.ts import { Body, Controller, Post } from '@nestjs/common'; import { createUserSchema, CreateUserDto } from '@acme/schema'; import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; import { UsersService } from './users.service'; @Controller('users') export class UsersController { constructor(private readonly users: UsersService) {} @Post() create( @Body(new ZodValidationPipe(createUserSchema)) dto: CreateUserDto, ) { return this.users.create(dto); } }
What just happened?
- Single source – the controller imports createUserSchema from the shared package.
- Runtime guard – ZodValidationPipe runs safeParse on the body.
- Typed parameter – Because the pipe returns result.data, NestJS now hands the method a fully‑typed
CreateUserDto
. No casting, no duplication, no decorators.
4. Consuming the schema in Next.js (apps/web)
Goal: Build a form that is type‑safe at compile time and validated at runtime—again, without rewriting rules.
Install helpers:
pnpm add zod react-hook-form @hookform/resolvers // apps/web/app/register/page.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { createUserSchema, CreateUserDto, } from '@acme/schema'; export default function RegisterPage() { const { register, handleSubmit, formState: { errors }, } = useForm<CreateUserDto>({ resolver: zodResolver(createUserSchema), }); const onSubmit = async (data: CreateUserDto) => { await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); }; return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <input {...register('email')} placeholder="Email" className="input" /> {errors.email && <p>{errors.email.message}</p>} <input {...register('password')} type="password" placeholder="Password" className="input" /> {errors.password && <p>{errors.password.message}</p>} <input {...register('name')} placeholder="Name" className="input" /> {errors.name && <p>{errors.name.message}</p>} <select {...register('role')} className="select"> <option value="ADMIN">Admin</option> <option value="CUSTOMER">Customer</option> </select> <button type="submit" className="btn-primary"> Create account </button> </form> ); }
What just happened?
- Same schema – The form resolver gets createUserSchema directly.
- Static types – useForm<CreateUserDto> makes every register call type‑checked.
- Runtime feedback – Zod runs on the client, giving immediate error messages identical to what the backend will enforce.
5. Optional niceties
Need Package One‑liner
- Auto‑generate OpenAPI from Zod nestjs-zod createZodDto() for Swagger decorators
- Infer React Query hooks from schemas zodios or tanstack-query-codegen Keep endpoints typed
- Reuse enums in the DB layer Export them from schema and import in Prisma/Drizzle models
6. Why this beats class‑validator + Zod duplication
- Zero drift: There’s literally no second copy to forget updating.
- Runtime everywhere: Both client and server throw the same validation errors, improving DX.
- Tree‑shakable: Zod schemas are just data; unused ones disappear from the bundle.
- Less meta‑programming: No decorators, no reflection—just plain functions.
If you already have a NestJS codebase full of class-validator classes, you can flip the strategy: keep the classes and generate Zod schemas with class-validator-to-zod. The core idea remains: write validation once, consume it everywhere.
7. Takeaways
- Put every schema in a tiny, framework‑agnostic package.
- Let adapters (ZodValidationPipe, zodResolver) connect those schemas to your frameworks.
- Enjoy type‑safe, runtime‑validated, duplication‑free code on both ends of the wire.
Typed end‑to‑end development isn’t about sprinkling TypeScript across services—it’s about erasing the seams between them. By trusting a single source of truth for data contracts, you ship faster, break less, and finally say goodbye to keeping two DTO files in sync ever again.
Check out our white-labeled web designing service