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, symlinked node_modules can resolve multiple copies of reflect-metadata across packages, which breaks NestJS decorators in non-obvious ways. One line in .npmrc fixes it.
  • TypeScript 5.9.3, not 6.0. NestJS 11 uses experimentalDecorators and emitDecoratorMetadata. TypeScript 6 changed decorator semantics. We'll upgrade when NestJS officially says it's safe.
  • packages/types holds Zod schemas. TypeScript types are inferred from those schemas with z.infer<>. The NestJS DTO implements the 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
12
apps/
  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:

  • typecheck depends on ^build, not ^typecheck. The instinct is to write "dependsOn": ["^typecheck"] - run dependencies' typecheck before mine. That's wrong. apps/api imports from @repo/types, which is a compiled package. TypeScript resolves @repo/types by following the main field in packages/types/package.json to dist/index.js. That dist/ directory doesn't exist until the package is built, not typechecked. On a fresh clone, typecheck will fail every time until you either run build first 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_URL isn't declared in the dev task's env array, 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':

1
node-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
5
git 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:

1
pnpm 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:

NeedWhat to add
DatabasePrisma in 'packages/database' with shared client and generated types
AuthNextAuth.js in 'apps/web', JWT guards + Passport in 'apps/api'
CMSContentful SDK in 'apps/web' (covered in a follow-up article)
EmailResend or Nodemailer service in 'apps/api'
PaymentsStripe webhook handler in 'apps/api', client-side checkout in 'apps/web'
Admin panelA 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?