Over the past five years building production systems, I've watched the NestJS validation landscape shift. Most teams reach for class-validator with class-transformer, but there's a simpler, more powerful alternative that solves a problem you didn't know you had: Zod as an alternative to class-validator gives you runtime validation, TypeScript types, and frontend schema sharing all from a single source of truth.
In this article, I'll show you why I've moved several projects away from decorators and how Zod bridges the gap between your API validation and frontend form handling.
Why I stopped using class-validator decorators
Class-validator works, and I've built plenty with it. But every project that scaled hit the same friction points:
- Type definitions live separately from validation rules, doubling the maintenance burden.
- Frontend schemas often become a parallel concern—you validate on the backend with decorators, then hand-write Zod or Yup schemas client-side.
- Circular dependencies emerge when you try to share DTOs across packages.
- Swagger generation requires a separate decorator layer.
Zod flips this approach. You write validation once, derive types for free, and share that schema with your Next.js frontend. No duplication, no sync issues.
z.infer gives the TypeScript type for free
This is the killer feature. With class-validator, you define a DTO class, then TypeScript infers its type:
// class-validator approach
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsOptional()
@IsString()
firstName?: string;
}
// TypeScript infers the type, but it's separate from validation
const user: CreateUserDto = { email: '...', password: '...' };With Zod, the schema IS the source of truth, and z.infer extracts the TypeScript type directly:
// schemas/user.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
firstName: z.string().optional(),
});
// Extract TypeScript type automatically
export type CreateUserDto = z.infer;
// Now CreateUserDto is perfectly typed with no extra work
const user: CreateUserDto = {
email: 'test@example.com',
password: 'securepass123'
};This single pattern eliminates the classic problem: your DTO class and its validation rules drift apart. Zod keeps them locked together. Change the validation rule, and your type updates instantly.
A small ZodValidationPipe replaces decorators
NestJS decorators are syntactic sugar for middleware. Zod validation happens in a pipe, which is cleaner because you're no longer spreading validation logic across multiple decorators.
Here's a custom pipe that handles Zod validation:
// pipes/zod-validation.pipe.ts
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown) {
try {
return this.schema.parse(value);
} catch (error: any) {
throw new BadRequestException({
message: 'Validation failed',
errors: error.errors.map((err: any) => ({
path: err.path.join('.'),
message: err.message,
})),
});
}
}
}Now your controller looks like this:
// controllers/users.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { createUserSchema } from '../schemas/user';
import { ZodValidationPipe } from '../pipes/zod-validation.pipe';
import { UsersService } from '../services/users.service';
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
async create(
@Body(new ZodValidationPipe(createUserSchema))
dto: CreateUserDto,
) {
return this.usersService.create(dto);
}
}Much cleaner. One pipe per endpoint, no decorator sprawl. The validation schema is explicit and reusable.
Share schemas with Next.js in a monorepo
This is where Zod shines for teams building modern fullstack applications. In a monorepo (using Nx, Turborepo, or pnpm workspaces), you can share the exact same schema between backend and frontend:
// packages/schemas/src/user.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
firstName: z.string().optional(),
age: z.number().min(18).max(120),
});
export type CreateUserDto = z.infer;In your NestJS backend, import it:
// apps/backend/src/controllers/users.controller.ts
import { createUserSchema } from '@myorg/schemas';
@Controller('users')
export class UsersController {
@Post()
async create(@Body(new ZodValidationPipe(createUserSchema)) dto) {
// dto is fully typed as CreateUserDto
return this.usersService.create(dto);
}
}In your Next.js frontend (with a form library like React Hook Form), use the same schema:
// apps/web/src/pages/signup.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createUserSchema, CreateUserDto } from '@myorg/schemas';
export function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(createUserSchema),
});
return (
{
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
return res.json();
})}>
{errors.email && <span>{errors.email.message}</span>}
{errors.password && <span>{errors.password.message}</span>}
Sign Up
);
}Your validation rules are now truly single-source-of-truth. If you change the email validation rule, both frontend and backend automatically enforce it. No more sync bugs where your API rejects what the form allowed.
nestjs-zod adds Swagger support
The only real gap with Zod in NestJS is Swagger documentation. Class-validator integrates natively with Swagger because it uses decorators. The community package nestjs-zod bridges this:
// Installation
npm install nestjs-zod @nestjs/swaggerConfigure it in your main.ts:
// main.ts
import { ZodValidationPipe } from 'nestjs-zod';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global Zod validation pipe
app.useGlobalPipes(new ZodValidationPipe());
const config = new DocumentBuilder()
.setTitle('My API')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(3000);
}
bootstrap();Then use the @ZodSerializationDecorator to document responses:
import { ZodSerializationDecorator } from 'nestjs-zod';
export const userResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
firstName: z.string().optional(),
createdAt: z.string().datetime(),
});
export type UserResponse = z.infer;
@Controller('users')
export class UsersController {
@Post()
@ZodSerializationDecorator(userResponseSchema)
async create(@Body(new ZodValidationPipe(createUserSchema)) dto) {
return this.usersService.create(dto);
}
@Get(':id')
@ZodSerializationDecorator(userResponseSchema)
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}Now your Swagger docs are auto-generated from Zod schemas. Request bodies and response types appear correctly in the OpenAPI spec.
Common mistakes
- Forgetting to handle Zod errors gracefully: If you don't format Zod error arrays cleanly, clients see cryptic nested paths. Always map errors to a consistent shape in your pipe.
- Not leveraging z.infer early: New teams sometimes still write separate TypeScript types alongside Zod schemas. Commit to one source of truth from day one.
- Schema bloat in one file: Create a schemas folder and organize by domain (schemas/user.ts, schemas/product.ts). Reuse and compose smaller schemas with z.object().merge().
- Sharing schemas without a proper monorepo setup: If you copy-paste schemas between projects, you've lost the benefit. Use a shared package or workspace.
- Skipping validation on internal service calls: Just because you validated at the HTTP boundary doesn't mean service-to-service calls are safe. Validate again where domain logic is sensitive.
Key takeaways
- Zod as an alternative to class-validator eliminates decorator sprawl and keeps validation logic and type definitions synchronized in one schema.
z.inferextracts TypeScript types automatically, removing the need to maintain separate DTO classes.- A small custom validation pipe replaces multiple field decorators, making validation easier to read and test.
- In a monorepo, sharing Zod schemas with your Next.js frontend means validation rules are truly single-source-of-truth across client and server.
nestjs-zodadds Swagger support so you don't sacrifice API documentation when moving away from class-validator.- The biggest win is shipping frontend and backend that agree on validation without manual syncing.






Comments
Loading comments…