Sharing Types Between NestJS and Next.js: DTOs, Validation and API Contracts in a Monorepo

Next.js on the front, NestJS on the back, TypeScript everywhere. On paper, the types flow end to end. In practice, most teams I join have two copies of every request and response shape: a DTO class in the API and a hand-written interface in the frontend. They agree on the day they're written and drift a little every sprint after that.
The failure mode is always the same. Someone renames fullName to displayName in the API, the NestJS tests pass, the Next.js build passes, and the profile page quietly renders undefined in production. The compiler had no way to know the two shapes were supposed to match.
This post covers the three ways to fix that, when each one fits, and the monorepo plumbing that tends to break first.
The three options at a glance
| Approach | Source of truth | Best when | Main cost |
|---|---|---|---|
| Shared Zod schemas | A contracts package both apps import | One team owns both apps in one repo | Schemas live outside Nest's decorator world |
| Generated OpenAPI client | The running NestJS API | The API has other consumers (mobile, partners) | A codegen step in the build |
| Shared class-validator DTOs | NestJS DTO classes | Almost never | Decorators and runtime deps leak into the frontend |
Option 1: Zod schemas in a shared contracts package
For a single product team with a Next.js app and a NestJS API in one repository, this is the default I'd reach for. You define each contract once, as a Zod schema, in a small workspace package. The API validates incoming requests with it. The frontend validates forms and Server Action input with the same schema and gets its TypeScript types via z.infer.
apps/
api/ # NestJS
web/ # Next.js
packages/
contracts/ # Zod schemas + inferred types, no framework code// packages/contracts/src/users.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.email(),
displayName: z.string().min(2).max(80),
role: z.enum(["admin", "member"]).default("member"),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export const UserSchema = CreateUserSchema.extend({
id: z.uuid(),
createdAt: z.iso.datetime(),
});
export type User = z.infer<typeof UserSchema>;On the NestJS side, nestjs-zod turns a schema into a DTO class that works with Nest's validation pipe, so controllers still look like idiomatic Nest:
// apps/api/src/users/users.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { createZodDto } from "nestjs-zod";
import { CreateUserSchema, type User } from "@acme/contracts";
class CreateUserDto extends createZodDto(CreateUserSchema) {}
@Controller("users")
export class UsersController {
constructor(private readonly users: UsersService) {}
@Post()
create(@Body() dto: CreateUserDto): Promise<User> {
return this.users.create(dto);
}
}
// app.module.ts: validate every request against its Zod DTO
// providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }]And the Next.js side reuses the exact same schema, both for the form and for the Server Action that forwards the request:
// apps/web/app/users/actions.ts
"use server";
import { CreateUserSchema, UserSchema } from "@acme/contracts";
export async function createUser(formData: FormData) {
const input = CreateUserSchema.safeParse(Object.fromEntries(formData));
if (!input.success) {
return { errors: input.error.flatten().fieldErrors };
}
const res = await fetch(process.env.API_URL + "/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input.data),
});
// Parse the response too: this is where silent drift gets caught.
return { user: UserSchema.parse(await res.json()) };
}Now the rename from the introduction fails loudly. Change displayName in the contracts package and the frontend stops type-checking in the same pull request. Parsing the response with UserSchema also catches the cases types can't: an API deployed ahead of the frontend, or a field that is null when the type says it isn't.
Option 2: generate a typed client from OpenAPI
If the API has consumers outside the monorepo, such as a mobile app, a partner integration or another team's service, make the API itself the source of truth. NestJS can describe itself as an OpenAPI document via @nestjs/swagger, and openapi-typescript turns that document into types for a small, fully typed fetch client:
// apps/api/scripts/openapi.ts: run in CI after the API builds
const app = await NestFactory.create(AppModule, { logger: false });
const config = new DocumentBuilder().setTitle("Acme API").build();
const document = SwaggerModule.createDocument(app, config);
writeFileSync("openapi.json", JSON.stringify(document, null, 2));
await app.close();npx openapi-typescript apps/api/openapi.json -o apps/web/lib/api/schema.d.ts// apps/web/lib/api/client.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export const api = createClient<paths>({ baseUrl: process.env.API_URL });
// Path, params and response are all checked against the API's spec:
const { data, error } = await api.GET("/users/{id}", {
params: { path: { id } },
});The trade-off is a codegen step, and a spec that is only as accurate as your decorators. If a controller returns something its @ApiResponse doesn't describe, the generated types will confidently lie. Treat a changed openapi.json as a reviewable artifact: commit it, and fail CI when the generated client is out of date.
The two options also combine. Keep Zod as the source of truth and let nestjs-zod emit OpenAPI schemas from it for the external consumers; check its docs for the setup that matches your version.
Why not just share the NestJS DTO classes?
It's the first thing everyone tries, because the classes already exist. It usually goes badly:
- Runtime baggage. class-validator and class-transformer decorators, plus
reflect-metadata, end up in the frontend bundle or break the build outright. - Server-only imports leak. DTO files tend to import enums from entities or services. One careless import and your Next.js build is pulling in TypeORM.
- Classes aren't the wire format. The JSON a client receives has strings where the DTO has
Dateobjects and no methods. The shared type describes something the frontend never actually gets.
The monorepo plumbing that breaks first
Most of the pain in this setup isn't Zod or NestJS; it's packaging. Three things to get right:
- Compile the contracts package. Next.js can consume a workspace package straight from TypeScript source (add it to
transpilePackages). The Nest CLI's defaulttscbuild can't: it won't compile files outside the API'srootDir, and you get errors likeUnexpected token 'export'at runtime. Buildpackages/contractswith tsup (or tsc) to JavaScript and declaration files, and point itsexportsat the output. - Order the builds. In Turborepo, give
builda"dependsOn": ["^build"]so contracts always builds before the apps that import it. - One Zod version. Pin Zod in the contracts package and let the apps get it from there. Two copies of Zod in one app give you schemas that fail
instanceofchecks in confusing ways.
// packages/contracts/package.json
{
"name": "@acme/contracts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": { "build": "tsup src/index.ts --format esm,cjs --dts" },
"dependencies": { "zod": "^4.0.0" }
}Rules that keep contracts healthy
- Contracts describe the API, not the database. Never export Prisma models or TypeORM entities as API types. The moment you do, a database migration becomes a breaking API change.
- Additive changes first. Add the new field, ship both apps, then remove the old one. Deploys are never perfectly simultaneous, even from one repository.
- Parse at the boundary. Types are a compile-time promise;
schema.parseon responses is what catches the day the promise breaks. - Keep it boring. The contracts package should have no framework imports and no business logic. If it needs NestJS or React to compile, something has leaked in.
Which one should you pick?
One team, one repository, one frontend: shared Zod schemas. The API has consumers you don't control: generate a client from OpenAPI, ideally with Zod still defining the shapes underneath. Shared DTO classes: only if you enjoy debugging bundler errors.
Setting this up on an existing codebase is usually a few days of work, and it pays for itself the first time a rename fails the build instead of reaching production. It's also the kind of foundation work I do as a NestJS contractor when I join a Next.js + NestJS team. If you'd like a second pair of eyes on your setup, get in touch.
Ready to hire a senior NestJS developer?
Let’s talk about your technical requirements. I offer a free discovery call where we’ll discuss architecture, tech stack, and timeline.
Hire a senior NestJS developer
