Web Engineering ·

TypeScript Monorepo Architecture: Turborepo, Nx, and pnpm Workspaces for Production Teams

How to structure a TypeScript monorepo for a growing team. Compares Turborepo, Nx, and plain pnpm workspaces across build performance, dependency management, CI integration, and developer experience.

TypeScript Monorepo Architecture: Turborepo, Nx, and pnpm Workspaces for Production Teams

At a certain team size, the question stops being “should we use a monorepo?” and starts being “which approach won’t collapse under our own weight?” Every tool in this space makes promises about build speed and developer experience. The gap between those promises and production reality is where teams get burned.

This article covers the structural and operational tradeoffs between Turborepo, Nx, and plain pnpm workspaces for TypeScript projects. Not a tour of features, but the decisions that matter when you’re shipping with a team of five-plus engineers across multiple packages.

What a Monorepo Actually Buys You

Before the tooling comparison, be clear on what you’re optimizing for. A monorepo is a version-control strategy, not a build tool. The reason teams consolidate is usually one or more of:

  • Atomic commits across packages (refactor an API and update all consumers in one PR)
  • Shared TypeScript configurations, ESLint rules, and test infrastructure
  • A single source of truth for internal package versioning
  • Easier cross-package type checking without publishing to npm

What it costs: increased CI complexity, longer clone times at scale, and the constant overhead of dependency graph hygiene. None of the tools below eliminate these costs. They reduce them.

The Baseline: pnpm Workspaces

Before reaching for Turborepo or Nx, understand what pnpm workspaces give you on their own. A pnpm-workspace.yaml file and a root package.json get you cross-package linking and hoisting with no additional tooling:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tools/*"
// packages/ui/package.json
{
  "name": "@acme/ui",
  "version": "0.1.0",
  "exports": {
    ".": "./src/index.ts"
  },
  "devDependencies": {
    "typescript": "^5.4.0"
  }
}
// apps/web/package.json
{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "workspace:*"
  }
}

The workspace:* protocol tells pnpm to resolve @acme/ui from the local filesystem. Type-checking across packages works because TypeScript follows the exports field through the package.json graph.

This setup handles maybe two or three apps and five packages reasonably well. What breaks down: you have no task orchestration. Running pnpm -r build across all packages runs them in dependency order, but there is no caching, no parallelism beyond what pnpm infers, and no remote cache sharing between CI runs. Every CI job rebuilds everything from scratch.

That is when you reach for a task runner.

Turborepo: Fast Builds with Minimal Config

Turborepo’s value proposition is a single turbo.json that describes your task graph, backed by a content-addressed local and remote cache. The mental model is simple: define what tasks exist, what they depend on, and which outputs to cache.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "tsconfig.json"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.ts", "test/**/*.ts"],
      "outputs": []
    },
    "lint": {
      "inputs": ["src/**/*.ts", ".eslintrc*"]
    }
  }
}

The ^build notation means “run build in all upstream dependencies first.” Turborepo computes the correct topological order and runs tasks in parallel where the graph allows it.

The cache key is computed from the input file hashes plus the task configuration. If nothing changed since the last run, Turborepo replays the cached output and exits immediately. On CI, point TURBO_TOKEN and TURBO_TEAM at Vercel’s remote cache (or self-host one) and cache hits are shared across all branches and all runners.

# First CI run: builds everything, uploads cache
turbo run build --cache-dir=".turbo"

# Second CI run on same commit: all cache hits, sub-second
CACHE HIT  @acme/ui:build (0.3s)
CACHE HIT  @acme/web:build (0.2s)

Where Turborepo is thin: it has almost no opinions about your package structure, module boundaries, or what generators to use for new packages. It is a task runner, not a workspace manager. That is mostly a feature, but it means you bring your own conventions.

Nx: Structured Workspaces with First-Class Constraints

Nx is a fuller framework. It handles task orchestration and caching similarly to Turborepo, but adds project graph visualization, enforced module boundaries, code generators, and a plugin system that knows how to configure TypeScript, Vite, Jest, and most major frameworks.

The core graph configuration looks like this:

// nx.json
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "cache": true,
      "inputs": ["default", "^default"]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^default", "{workspaceRoot}/jest.preset.js"]
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  }
}

The biggest differentiator is module boundary enforcement via @nx/eslint-plugin:

// .eslintrc.json (root)
{
  "plugins": ["@nx"],
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "scope:app",
            "onlyDependOnLibsWithTags": ["scope:shared", "scope:feature"]
          },
          {
            "sourceTag": "scope:feature",
            "onlyDependOnLibsWithTags": ["scope:shared"]
          },
          {
            "sourceTag": "scope:shared",
            "onlyDependOnLibsWithTags": ["scope:shared"]
          }
        ]
      }
    ]
  }
}

Each project in project.json carries tags:

// libs/auth/project.json
{
  "name": "auth",
  "tags": ["scope:feature", "domain:auth"]
}

Now the linter rejects any import that violates your declared dependency direction. A UI utility library cannot import from an application. A feature library cannot import from another feature domain. These constraints are enforced at the ESLint step, which runs on every PR.

This is valuable when the team is large enough that you can no longer rely on everyone knowing the intended architecture. It is also friction when you’re moving fast and the constraints do not yet reflect reality.

Nx also ships nx affected, which computes which projects are affected by a given commit range and runs tasks only for those:

# Only test and build packages affected by changes in this PR
nx affected --target=build --base=origin/main --head=HEAD
nx affected --target=test --base=origin/main --head=HEAD

Turborepo has equivalent filtering via --filter, but Nx’s project graph awareness is deeper, especially when using Nx plugins that understand framework-specific build graphs.

Package Boundaries and tsconfig Structure

Regardless of your task runner, TypeScript configuration layout is where most teams create long-term pain. The pattern that scales:

tsconfig.base.json          # Root: paths, strict settings, shared compiler options
packages/
  ui/
    tsconfig.json           # Extends base, adds package-specific outDir
    tsconfig.build.json     # Excludes test files, used by tsc --build
apps/
  web/
    tsconfig.json           # Extends base, references packages it depends on
// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "moduleResolution": "bundler",
    "target": "ESNext",
    "module": "ESNext",
    "declaration": true,
    "declarationMap": true,
    "composite": true,
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@acme/ui": ["packages/ui/src/index.ts"],
      "@acme/auth": ["packages/auth/src/index.ts"]
    }
  }
}
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "references": [
    { "path": "../../packages/ui" },
    { "path": "../../packages/auth" }
  ]
}

Using composite: true and references enables TypeScript project references, which let tsc --build do incremental compilation across the entire workspace. This is separate from your task runner’s caching. TypeScript’s own build cache lives in .tsbuildinfo files, and Turborepo or Nx will cache those along with your other outputs.

The paths in tsconfig.base.json map package names to source files directly, which means your IDE resolves types without a build step. The exports in each package’s package.json handle runtime resolution. These two need to stay in sync.

CI Integration

The practical difference between tools shows up most clearly in CI setup.

With pnpm workspaces alone, you typically install everything and run tasks across all packages. No incremental anything:

# .github/workflows/ci.yml (naive)
- run: pnpm install
- run: pnpm -r typecheck
- run: pnpm -r test
- run: pnpm -r build

With Turborepo and remote cache:

- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build typecheck test lint
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

Every task that was already computed from an identical input set is a cache hit. On a team where most PRs touch one or two packages, CI times drop significantly because only affected subgraphs rebuild.

With Nx, you can also use Nx Cloud for remote caching, or self-host with Nx’s open-source remote cache. The affected filtering is often more precise:

- run: pnpm install --frozen-lockfile
- run: pnpm nx affected --target=build --base=origin/main
- run: pnpm nx affected --target=test --base=origin/main

Self-hosted remote cache options exist for both tools. Turborepo supports any S3-compatible bucket via community adapters. Nx has a self-hosted Nx Cloud option. If you cannot send build artifacts to a third-party service for compliance reasons, plan this before committing to either.

Tradeoffs

Factorpnpm Workspaces (plain)TurborepoNx
Setup timeLowLow-mediumMedium-high
Task cachingNoneContent-addressed, remoteContent-addressed, remote
Affected-only runsNoYes (—filter)Yes (nx affected, deeper graph)
Module boundary enforcementNoNoYes (lint rules + tags)
Code generatorsNoBasic (turbo gen)Full plugin system
Config verbosityLowLowMedium
Plugin ecosystemn/aMinimalExtensive (Next.js, Vite, etc.)
Migration costn/aLowMedium
Learning curveLowLowMedium
Vendored CI serviceNoVercel (optional)Nx Cloud (optional)

Production Considerations

Cache invalidation is your biggest operational risk. If your cache inputs are too broad, you get cache misses constantly and gain nothing. If they are too narrow, you get incorrect cache hits and ship broken code. Audit your inputs configuration carefully. Inputs should include all files that affect the output: source files, config files that change behavior, and any environment variables baked into the build.

Lock your pnpm version. Use packageManager in the root package.json and enable Corepack. A mismatch between local and CI pnpm versions changes the lockfile format and can bust caches unexpectedly.

// package.json (root)
{
  "packageManager": "pnpm@9.4.0"
}

Shared tsconfig changes are high-blast-radius. Any change to tsconfig.base.json invalidates every package that extends it. This is correct behavior, but engineers do not always expect it. Document this explicitly so someone does not blindly add a compiler option and wonder why CI rebuilt the entire workspace.

Watch out for phantom dependencies. pnpm’s strict node_modules layout (non-hoisted by default) will surface imports that work accidentally because a transitive dependency happens to be installed. This is painful to discover mid-migration. Run with shamefully-hoist=false (the default) early and fix violations before they accumulate.

Package boundaries are only as good as enforcement. Tags and lint rules in Nx sound great, but they need to reflect your actual intended architecture. Spend time defining your tag taxonomy before you have fifty packages, not after. With Turborepo, you rely on convention and code review, which works fine at smaller scale.

When to abandon the monorepo: If your packages have meaningfully different release cadences, different deployment pipelines with no shared code, or teams that rarely interact, you are paying the coordination cost of a monorepo without getting the atomic-commit benefit. A well-structured polyrepo with published internal packages is not a step backward. It is the right call for some organizations.

Choosing

For a team of two to five engineers, start with pnpm workspaces and add Turborepo when CI times become a problem. The config surface is small, the cache model is correct, and you can migrate incrementally.

For a team of five to twenty with shared infrastructure, overlapping domains, and engineers who should not need to understand the whole graph to stay productive, Nx’s boundary enforcement and generator scaffolding reduce coordination overhead enough to justify the setup cost.

Plain pnpm workspaces without a task runner are for small, stable codebases where build times are not a constraint. They are not a permanent architecture for a growing product.

The tools are converging. Turborepo and Nx have learned from each other over the past two years. The structural decision, how you define your package graph and enforce boundaries, matters more than which task runner you pick. Get that right first.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.