How to Structure a Full-Stack TypeScript Monorepo: Next.js, NestJS, Turborepo
Piotr Żarów
CTO at Dev and Deliver
2026-07-27
#Development
Time to read
12 mins
In this article
The short version
Do you actually need a monorepo?
Repo structure
How to share types between NestJS and Next.js
Turbo pipeline decisions
Why pnpm needs hoisting for NestJS
Real pain points
Getting started
What to layer on top
FAQ
Agency take
Share this article
Introduction
A Turborepo monorepo with Next.js and NestJS is worth the setup complexity for exactly one reason: you define your data types once, and TypeScript enforces them everywhere - in the NestJS controller that validates the incoming HTTP request and in the Next.js form that submits it. That's it. If your project doesn't have both a frontend and a backend, reach for something simpler. If it does, this setup pays for itself within the first sprint.
We open-sourced the starter we actually use: https://github.com/DevAndDeliver/turborepo.
The short version
- Turborepo 2.9.18 orchestrates builds across the monorepo with caching. The pipeline config has real gotchas - we document them all.
- pnpm with
node-linker=hoisted. pnpm's default strict, symlinkednode_modulescan resolve multiple copies ofreflect-metadataacross packages, which breaks NestJS decorators in non-obvious ways. One line in.npmrcfixes it. - TypeScript 5.9.3, not 6.0. NestJS 11 uses
experimentalDecoratorsandemitDecoratorMetadata. TypeScript 6 changed decorator semantics. We'll upgrade when NestJS officially says it's safe. packages/typesholds Zod schemas. TypeScript types are inferred from those schemas withz.infer<>. The NestJS DTOimplementsthe inferred type - TypeScript enforces the match at compile time, class-validator enforces it at runtime.- Five real things broke during setup. They're all documented below, including the fix.
Do you actually need a monorepo?
The honest answer is: most projects don't need one.
A Next.js app that talks directly to Contentful or Supabase doesn't need a monorepo. You get the CMS SDK, you get type safety from the CMS-generated types, and you're done. Adding Turborepo on top of a single-app project is infrastructure for its own sake.
The monorepo earns its existence the moment you have two apps that need to agree on a data shape. In our case: apps/web (Next.js, the user-facing frontend) and apps/api (NestJS, the REST API). The landing page for a client project has a waitlist form. The form POSTs a JSON payload to the API. The API validates that payload and stores the lead.
Without a monorepo and shared types, you write the payload shape twice - once as a TypeScript interface in the frontend, once as a NestJS DTO in the backend. They drift. A developer adds a field on the backend, forgets to update the frontend type, and you don't find out until QA. With packages/types, you write it once. TypeScript tells you immediately when the two sides disagree.
That's the whole case. If you have that problem, read on.
Repo structure
1 2 3 4 5 6 7 8 9 10 11 12apps/ web/ Next.js 16, App Router, Tailwind CSS v4 api/ NestJS 11, REST, Swagger on /docs packages/ types/ Shared Zod schemas + z.infer<> TypeScript types ui/ Shared React components (Button, Input) config/ Shared tsconfig and ESLint configs turbo.json pnpm-workspace.yaml .npmrc package.json prettier.config.js
apps/web runs on port 3000. apps/api runs on port 3001 with Swagger at /docs. packages/config has no compiled output - it's just config files consumed directly. packages/types and packages/ui both compile to dist/ via tsc. Prettier's config lives at the repo root, not in packages/config - unlike ESLint and tsconfig, it isn't run per-workspace via Turbo, so it doesn't need to be an importable package.
The dependency graph is one-directional: apps depend on packages, packages don't depend on apps. packages/ui can depend on packages/types if needed. Nothing flows the other way.
How to share types between NestJS and Next.js
This is the part that makes the architecture worthwhile, so let's go deep.
Step 1: Define the schema in 'packages/types'
1 2 3 4 5 6 7 8// packages/types/src/waitlist.ts import { z } from "zod"; export const CreateWaitlistEntrySchema = z.object({ email: z.string().email(), }); export type CreateWaitlistEntry = z.infer<typeof CreateWaitlistEntrySchema>;
Zod is the source of truth. The TypeScript type is inferred from the schema - you don't maintain them separately. Add a field to the schema and the type updates automatically.
Step 2: The NestJS DTO implements the shared type
1 2 3 4 5 6 7 8 9 10// apps/api/src/waitlist/dto/create-waitlist.dto.ts import { ApiProperty } from "@nestjs/swagger"; import { IsEmail } from "class-validator"; import type { CreateWaitlistEntry } from "@repo/types"; export class CreateWaitlistDto implements CreateWaitlistEntry { @ApiProperty({ example: "jane@example.com" }) @IsEmail() email!: string; }
implements CreateWaitlistEntry is the load-bearing line. TypeScript will refuse to compile this DTO if its properties don't match the type from packages/types. Add a required field to the Zod schema without updating the DTO, and you get a compile error immediately - in CI, before deployment.
Step 3: Next.js uses the same schema for client-side validation
1 2 3 4 5 6 7 8 9 10 11 12 13 14// apps/web/src/components/contact/WaitlistForm.tsx import { CreateWaitlistEntrySchema } from "@repo/types"; async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); const raw = { email: new FormData(e.currentTarget).get("email") as string }; const result = CreateWaitlistEntrySchema.safeParse(raw); if (!result.success) { // show field errors from Zod - no network round-trip for format errors return; } // POST result.data to the API }
The frontend validates against the same CreateWaitlistEntrySchema before the request goes out. Same schema, both ends. If the shape changes, TypeScript breaks both the DTO and the form until you fix them both.
Why Zod in 'packages/types' and class-validator in 'apps/API'?
We use Zod for type inference - z.infer<> is how we get TypeScript types out of schema definitions. Zod is excellent at that. We use class-validator for NestJS runtime validation because NestJS's ValidationPipe is built around class-validator decorators, and the NestJS ecosystem (Swagger's @ApiProperty(), Guards, Pipes) all integrate natively with class-decorated DTOs.
The two tools do different jobs. Zod defines the shape. class-validator enforces it at the API boundary at runtime. The implements keyword is the bridge - TypeScript ensures the DTO's shape matches the Zod-inferred type at compile time. You get the best of both ecosystems without duplicating your type definitions.
Turbo pipeline decisions
The 'turbo.json' that actually works on a fresh clone
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"], "env": ["NODE_ENV", "CONTENTFUL_SPACE_ID", "CONTENTFUL_ACCESS_TOKEN"] }, "dev": { "cache": false, "persistent": true, "env": [ "NODE_ENV", "NEXT_PUBLIC_API_URL", "DATABASE_URL", "CONTENTFUL_SPACE_ID", "CONTENTFUL_ACCESS_TOKEN", "RESEND_API_KEY", "RESEND_AUDIENCE_ID", "ALLOWED_ORIGIN", "PORT" ] }, "typecheck": { "dependsOn": ["^build"], "outputs": [] }, "lint": {} } }
Two non-obvious decisions here:
typecheckdepends on^build, not^typecheck. The instinct is to write"dependsOn": ["^typecheck"]- run dependencies' typecheck before mine. That's wrong.apps/apiimports from@repo/types, which is a compiled package. TypeScript resolves@repo/typesby following themainfield inpackages/types/package.jsontodist/index.js. Thatdist/directory doesn't exist until the package is built, not typechecked. On a fresh clone,typecheckwill fail every time until you either runbuildfirst or correct the dependency. The correct config is"dependsOn": ["^build"].- Declare your env vars or pay for it later. Turborepo caches build outputs. The cache key includes inputs - source files, turbo.json, and explicitly declared env vars. If
NEXT_PUBLIC_API_URLisn't declared in thedevtask'senvarray, Turbo doesn't know it matters. You change the API URL, Turbo serves a cached build from before the change, your frontend is calling the wrong endpoint. This is the most confusing Turborepo failure mode because there's no error - it just silently uses the stale cache.
Why pnpm needs hoisting for NestJS
Add this to your '.npmrc':
1node-linker=hoisted
That's the entire relevant line. It's also the one that decides whether NestJS boots at all.
pnpm's default install strategy is strict and symlinked: every package only sees the dependencies it actually declares, resolved through a content-addressable store and a web of symlinks. It's the whole point of pnpm - it catches phantom dependencies other package managers let slide. It's also incompatible with how NestJS resolves decorator metadata.
NestJS relies on emitDecoratorMetadata (TypeScript's decorator metadata feature) and reflect-metadata. The way reflect-metadata works depends on every module instance loaded from the same package being the same object in memory - it patches a single global Reflect. Under pnpm's strict linking, different packages in the dependency graph can end up resolving their own separate, nested copy of reflect-metadata instead of one shared instance. Decorators get registered against the wrong Reflect, and dependency injection breaks.
"Silently" is the key word. You start the API, Swagger is empty. Guards never run. @Injectable() does nothing. No stack trace, no error message. You spend an hour before you realize the module graph has more than one reflect-metadata in it.
node-linker=hoisted tells pnpm to flatten node_modules - same idea as classic npm/Yarn 1, one real directory, one shared reflect-metadata. You lose some of pnpm's phantom-dependency protection across the whole workspace. You gain a NestJS stack that actually works. We verified this isn't just theoretical - we boot apps/api under it as part of CI and watch the routes map correctly.
This is the #1 gotcha for NestJS + pnpm. Every production NestJS monorepo using pnpm needs this line (or the narrower public-hoist-pattern[]=reflect-metadata, which only hoists the one package - we went with the broader fix because it's the one that matches how Yarn's nodeLinker: node-modules used to behave, and it's more resilient to a new dependency introducing its own nested reflect-metadata copy later).
Real pain points
Five things went wrong during the initial setup. Here they are, with the exact fix for each.
1. pnpm's strict linking breaks NestJS silently
Covered above. Fix: node-linker=hoisted in .npmrc. Worth repeating because it's the one that costs the most time.
2. Why 'typecheck' Fails on a Fresh Clone
Covered in the Turbo section above. The short version: compiled packages need to be built before TypeScript can import from them, not just typechecked. Fix: "dependsOn": ["^build"] for the typecheck task.
3. ESLint 9 and 'eslint-config-next' via FlatCompat crash
When we tried to use eslint-config-next's next/core-web-vitals preset through ESLint 9's flat config compatibility layer (FlatCompat), we got Converting circular structure to JSON errors at runtime. ESLint would exit without linting anything.
The cause is that eslint-config-next was written for ESLint's legacy config format and some of its plugins produce circular structures when serialized through FlatCompat. The fix is to drop FlatCompat entirely: use eslint-config-next's flat export if it has one, or - what we ended up doing - just use typescript-eslint rules directly in the Next.js app and skip the Next.js lint preset. We already have custom eslint.config.js files in each package; there's no reason to go through the Next.js wrapper.
4. Why GitHub Actions Can't Find pnpm
Unlike Yarn classic, pnpm isn't bundled with Node.js on GitHub's hosted runners. You push to GitHub with "packageManager": "pnpm@11.17.0" declared in package.json and no explicit setup step, and CI fails immediately with pnpm: command not found. Locally it's invisible because Corepack quietly shimmed pnpm for you the first time you ran it.
The fix is the same shape as the old Yarn gotcha, just with a louder failure: enable Corepack before actions/setup-node. It matters for cache: pnpm too - that step shells out to pnpm store path to find what to cache, which only works once pnpm is actually resolvable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14# This must come before setup-node - name: Enable Corepack run: corepack enable - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm turbo typecheck - run: pnpm turbo lint - run: pnpm turbo test - run: pnpm turbo build
corepack enable before setup-node. One line. Not documented anywhere obvious in the GitHub Actions docs or pnpm's CI guide.
5. 'next lint' broke in Next.js 16
next lint in some Next.js 16 configurations requires an explicit directory argument. The failure mode is an unhelpful error that doesn't make it obvious what's wrong. The simpler fix: skip next lint entirely and call eslint src/ directly. You have an eslint.config.js already - there's no need to go through the Next.js wrapper. The Turbo lint task calls eslint src/ in each app.
Bonus: "type": "module" on CJS-output packages
This one is subtle. packages/ui and packages/types both use ESM syntax in their ESLint configs (because the packages/config ESLint base uses ESM). At some point we added "type": "module" to their package.json files to make Node happy with the ESLint config syntax.
That broke the compiled output. These packages compile to CommonJS via tsc. Adding "type": "module" tells Node to treat all .js files in the package as ESM modules - including the compiled dist/*.js files. Imports from apps/api and apps/web break because NestJS expects CJS.
The fix: packages/config (which has no compiled output, just config files) gets "type": "module". The other packages rename their ESLint config from eslint.config.js to eslint.config.mjs. Node respects the .mjs extension as ESM regardless of the type field.
Getting started
1 2 3 4 5git clone https://github.com/pzarow/turborepobydnd cd turborepobydnd corepack enable pnpm install pnpm dev
Web at http://localhost:3000, API at http://localhost:3001, Swagger at http://localhost:3001/docs.
Copy .env.example to .env.local in apps/web and to .env in apps/api. The only required env var to get the dev server running is NEXT_PUBLIC_API_URL=http://localhost:3001.
To typecheck the entire monorepo:
1pnpm typecheck
This works on a fresh clone without running build separately. Because typecheck in turbo.json declares "dependsOn": ["^build"], Turborepo automatically compiles packages/types and packages/ui before running typecheck in the apps. The dependency chain is handled by the pipeline, not by the developer.
What to layer on top
The starter is intentionally minimal. It's a foundation, not a finished product. Here's what we add on client projects:
| Need | What to add |
|---|---|
| Database | Prisma in 'packages/database' with shared client and generated types |
| Auth | NextAuth.js in 'apps/web', JWT guards + Passport in 'apps/api' |
| CMS | Contentful SDK in 'apps/web' (covered in a follow-up article) |
| Resend or Nodemailer service in 'apps/api' | |
| Payments | Stripe webhook handler in 'apps/api', client-side checkout in 'apps/web' |
| Admin panel | A separate 'apps/admin' (Next.js) sharing the same 'packages/types' |
For the database row specifically, the repo's README has a full walkthrough - scaffolding Prisma in a shared packages/database, an example schema, running the first migration, wiring it into apps/api, and the docker-compose.yml service to add so Postgres runs locally alongside apps/web and apps/api.
The point of a third apps/admin entry is worth noting: it gets shared types for free. Adding an admin dashboard to a monorepo that already has shared types costs almost nothing in architectural overhead. You add the app, import from @repo/types, and TypeScript enforces consistency across all three apps automatically.
FAQ
Do I need a monorepo for a Next.js and NestJS project?
Most projects don't. It pays off when you have two or more apps that share types, and the cost is a day of front-loaded setup for pnpm, Turborepo and the typecheck pipeline.
Why Zod in 'packages/types' but class-validator in 'apps/api'?
Zod is used for type inference - z.infer<> is how the TypeScript types come out of the schema definitions. class-validator handles NestJS runtime validation, because that's what integrates with the framework's DTO and pipe system. Each tool does the job it's best at.
Why does pnpm break NestJS silently?
pnpm's strict symlinked node_modules layout doesn't match what NestJS expects at runtime. The fix is node-linker=hoisted in .npmrc. This is the single failure that costs the most time to diagnose.
Why does 'turbo typecheck' fail on a fresh clone?
Because dependsOn: ["^typecheck"] is wrong. Compiled packages must be built before TypeScript can import from them, not merely typechecked - so the dependency has to be ^build.
Does GitHub Actions include pnpm?
No. Unlike Yarn classic, pnpm isn't bundled with Node.js on GitHub's hosted runners, even with "packageManager" declared in package.json. Install it with pnpm/action-setup before setup-node, or the Node cache step fails looking for a lockfile it can't parse.
Agency take
We use this stack on client projects. The setup complexity is front-loaded - you spend a day getting pnpm, Turborepo, and the typecheck pipeline right. After that, the ongoing cost is near zero.
The moment that makes developers believers is adding a new field to a form. You add it to the Zod schema in packages/types. TypeScript immediately shows you two red lines: one in the NestJS DTO (you need to add the field and its validators), one in the Next.js form component (you need to add the input). You fix both. You push. The frontend and backend are in sync by construction, not by discipline.
That's not a developer-experience nicety. On agency projects with multiple developers working across frontend and backend simultaneously, "in sync by construction" is the difference between a smooth sprint and a debugging session on the day of a client demo.
The starter is open source: https://github.com/DevAndDeliver/turborepo. Clone it, run it, see if the pattern fits your project. If you're building something with Next.js and a real backend - not a BFF, a real separate API - it probably does.
Dev and Deliver is a software agency based in Kraków, Poland. We build production web and mobile products for startups and scale-ups. devanddeliver.com
Piotr Żarów
CTO at Dev and Deliver
Share this post
Related posts
Want to light up your ideas with us?
Kickstart your new project with us in just 1 step!
Prefer to call or write a traditional e-mail?









