Share Types Across Next.js & NestJS with Zod

Chandrashekhar Fakirpure

Apr 10, 2025

Share:

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

Start Project

Bring your vision to life with a custom website. Let's chat about your goals.

Let's Collab

Your next departure starts here

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG

CONSOLE.LOG