Monorepo 2026: Turborepo, Nx, and pnpm Workspaces — Managing Code for Large Teams

Posted on: 4/21/2026 4:10:45 AM

36M+ Nx NPM downloads/month (2026)
90% CI time reduction with Remote Caching
70-85% Faster builds than polyrepo
850+ Microservices in the JPMorgan monorepo

Monorepo vs Polyrepo: the real trade-off

When a project grows from 1 app into 5-10 packages, the first question is always: one repo or separate repos? A monorepo puts everything — applications, shared libraries, config, CI/CD — into a single Git repository. A polyrepo splits each package into its own repo.

Neither model is strictly better. Polyrepo gives each team full autonomy over its release cycle, but keeping versions in sync across repos becomes a nightmare as the package count grows. Monorepo eliminates dependency drift but demands strong tooling to keep builds fast and CI clean.

graph LR
    subgraph Polyrepo
        R1[app-web repo]
        R2[app-mobile repo]
        R3[shared-ui repo]
        R4[shared-utils repo]
        R1 -.->|npm publish| R3
        R1 -.->|npm publish| R4
        R2 -.->|npm publish| R3
        R2 -.->|npm publish| R4
    end
    subgraph Monorepo
        M[Single Repository]
        M --> A1[packages/app-web]
        M --> A2[packages/app-mobile]
        M --> A3[packages/shared-ui]
        M --> A4[packages/shared-utils]
    end
    style R1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style R2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style R3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style R4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style M fill:#e94560,stroke:#fff,color:#fff
    style A1 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style A2 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style A3 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style A4 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50

Polyrepo: each package publishes via the npm registry. Monorepo: direct links through the workspace protocol.

CriterionMonorepoPolyrepo
Dependency syncSingle lockfile, versions always alignedSeparate lockfiles, drift is easy
Code sharingImport directly, no publishing neededMust publish to a registry and then install
Atomic changesOne PR can change both API and frontendMust sync multiple PRs across repos
CI/CD complexityNeeds affected detection and cachingSimple: one pipeline per repo
Git performanceNeeds sparse checkout at scaleEach repo stays light
Team autonomyModule boundaries + CODEOWNERSFully autonomous

pnpm Workspaces — the dependency-management foundation

pnpm Workspaces is the foundation layer for JavaScript/TypeScript monorepos. It solves the most basic problem: wiring up internal packages without having to publish them to the npm registry.

Unlike npm and Yarn, pnpm uses content-addressable storage — each version of a package is stored on disk only once, no matter how many projects use it. Combined with a symlink-based node_modules, you get much faster installs and real disk-space savings.

A typical pnpm workspace layout

monorepo/
├── pnpm-workspace.yaml          # Declares workspace packages
├── package.json                  # Root scripts + devDependencies
├── pnpm-lock.yaml               # Single lockfile for the entire workspace
├── apps/
│   ├── web/                      # Vue.js frontend
│   │   └── package.json
│   └── api/                      # .NET or Node.js backend
│       └── package.json
├── packages/
│   ├── ui/                       # Shared UI components
│   │   └── package.json
│   ├── utils/                    # Shared utilities
│   │   └── package.json
│   └── config/                   # Shared ESLint, TypeScript config
│       └── package.json
└── turbo.json                    # Turborepo task config (optional)

The pnpm-workspace.yaml file is just a few lines:

packages:
  - 'apps/*'
  - 'packages/*'

For an app to use a shared package, declare the dependency with the workspace:* protocol:

// apps/web/package.json
{
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:*"
  }
}

What is the workspace protocol?

workspace:* tells pnpm this package lives inside the monorepo and to link directly to its source code instead of downloading from the npm registry. At publish time, pnpm automatically replaces workspace:* with the real version.

pnpm Workspaces only manages dependencies — no task orchestration, caching, or affected detection. That's why teams typically pair pnpm with Turborepo or Nx.

Turborepo — a high-speed task runner

Turborepo (by Vercel) does one thing well: run tasks as fast as possible via smart caching and parallel execution. Its philosophy is "cache the outputs of the scripts you already have" — don't change how the team writes code, just accelerate the build pipeline.

Content-aware hashing

For each task run, Turborepo computes a hash from: source files, dependencies, environment variables, and tool versions. If the hash hasn't changed, the result is restored from cache in ~50ms instead of rebuilding from scratch.

graph TD
    A[turbo build] --> B{Hash already cached?}
    B -->|Yes| C[Restore from cache ~50ms]
    B -->|No| D[Run the actual build]
    D --> E[Save output into the cache]
    C --> F[FULL TURBO]
    E --> F
    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style C fill:#4CAF50,stroke:#fff,color:#fff
    style D fill:#2c3e50,stroke:#fff,color:#fff
    style E fill:#2c3e50,stroke:#fff,color:#fff
    style F fill:#e94560,stroke:#fff,color:#fff

Turborepo content-aware hashing: when the hash matches, cache hits take ~50ms.

Task pipeline and topological ordering

The turbo.json file defines task dependencies:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true
    },
    "lint": {
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

"dependsOn": ["^build"] means: before building this package, first build all of its dependencies. Turborepo auto-orders tasks topologically and runs independent ones in parallel.

Remote caching

Local cache helps one developer. Remote cache helps the whole team. When developer A has built package X, developer B clones the repo and runs the build — Turborepo detects the hash match, downloads the result from remote cache instead of rebuilding. CI benefits the same way.

# Connect to Vercel Remote Cache
npx turbo login
npx turbo link

# Or self-hosted server
# turbo.json:
{
  "remoteCache": {
    "signature": true
  }
}

When is Turborepo the right pick?

JavaScript/TypeScript teams of 3-20 people, under 30 packages, who want fast setup (<30 minutes) without learning a new framework. Turborepo is "plug-and-play": add turbo.json, run turbo build — done.

Filter syntax for selective builds

Turborepo lets you selectively target packages to build:

# Build only app-web and its dependencies
turbo build --filter=@myorg/web

# Build packages changed since the previous commit
turbo build --filter=...[HEAD~1]

# Build every package under apps/
turbo build --filter=./apps/*

Nx — a complete monorepo management ecosystem

If Turborepo is a "pure task runner," Nx is a "build system + workspace manager." Nx doesn't just run tasks fast — it understands your entire workspace: dependency graph, module boundaries, project generators, and can distribute task execution across machines.

Affected detection — build only what changed

Nx analyzes the dependency graph at the file level, not just the package level. When a single file changes in packages/utils, Nx identifies exactly which apps are impacted:

# Test only the projects affected by these changes
nx affected -t test

# Build only the affected apps
nx affected -t build

# Compare against main branch
nx affected -t lint --base=main --head=HEAD
graph TD
    subgraph "Dependency Graph"
        UI[packages/ui]
        Utils[packages/utils]
        Config[packages/config]
        Web[apps/web]
        Mobile[apps/mobile]
        Admin[apps/admin]
    end
    Web --> UI
    Web --> Utils
    Mobile --> UI
    Mobile --> Utils
    Admin --> UI
    Admin --> Config
    UI --> Utils

    subgraph "Affected by an edit to Utils"
        direction TB
        AUtils[Utils edited]
        AUI[UI rebuild]
        AWeb[Web rebuild]
        AMobile[Mobile rebuild]
        AAdmin[Admin rebuild]
    end

    style UI fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style Utils fill:#e94560,stroke:#fff,color:#fff
    style Config fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style Web fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style Mobile fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style Admin fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style AUtils fill:#e94560,stroke:#fff,color:#fff
    style AUI fill:#ff9800,stroke:#fff,color:#fff
    style AWeb fill:#ff9800,stroke:#fff,color:#fff
    style AMobile fill:#ff9800,stroke:#fff,color:#fff
    style AAdmin fill:#ff9800,stroke:#fff,color:#fff

Edit Utils, and Nx auto-detects that UI, Web, Mobile, and Admin are all affected and need rebuilding.

Module boundaries — prevent architectural drift

One of Nx's most powerful features is enforced module boundaries. The team declares rules: "apps may only import from libs, libs may not import apps, libs of type:feature may not import other type:feature libs."

// project.json or package.json
{
  "tags": ["type:feature", "scope:auth"]
}

// .eslintrc.json — root
{
  "rules": {
    "@nx/enforce-module-boundaries": ["error", {
      "depConstraints": [
        { "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:util"] },
        { "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:util"] },
        { "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:util"] }
      ]
    }]
  }
}

If a developer accidentally imports a feature lib from a UI lib, ESLint flags the error instantly. No need for code review to catch it — tooling does the work.

Code generators — create projects from templates

Nx generators automatically create new projects with full config, following team standards:

# Create a new Vue library
nx generate @nx/vue:library shared-components --directory=packages

# Create a new Node.js API
nx generate @nx/node:application payment-service --directory=apps

# Custom team generator
nx generate @myorg/workspace-plugin:feature-lib auth-module

Generators perform AST-level TypeScript transformations, automatically updating tsconfig paths, workspace references, and CI config.

Distributed task execution — Nx Agents

With a large monorepo, builds on a single machine are still slow even with caching. Nx Agents distribute workload across machines, using historical timing data to balance the load:

SetupTurborepoNx
Single-machine CI25m 32s21m 56s
Distributed (4 machines)Not supported natively9m 20s
Improvement vs single57% faster

When is Nx the right pick?

Teams of 10+, 30+ packages, with a need for architectural enforcement, code generation, and distributed CI. Nx fits especially well in polyglot repos (TypeScript + .NET + Python) thanks to its rich plugin ecosystem.

Bazel — the tech-giants' weapon

Bazel (by Google) operates on a whole different scale: 100,000+ source files, hundreds of developers, many languages. It offers hermetic builds (fully isolated from the local environment), fine-grained caching at the action level (smaller than a task), and remote execution that distributes builds across a cluster of machines.

Real case studies

  • Stripe: 300+ services in one monorepo, CI down from 45 minutes to under 7
  • JPMorgan Chase: 850+ microservices, build time cut by 40%
  • Google: 100,000 source files, decides what needs recompiling in ~200ms

However, Bazel has a very steep learning curve: you must learn Starlark (its own config language) and write detailed BUILD files for every dependency. It only fits teams of 100+ engineers or complex polyglot codebases.

Side-by-side: Turborepo vs Nx vs Bazel

CriterionTurborepoNxBazel
Setup time< 30 minutes1–2 hours1–2 weeks
Learning curveLowModerateVery high
Task cachingContent-aware hashingNamed inputs, ingAction-level, hermetic
Remote cachingVercel or self-hostedNx Cloud or self-hostedRemote Build Execution
Affected detectionFilter syntaxGraph-based, file-levelFine-grained, ~200ms
Distributed CINot nativeNx AgentsRemote Execution
Code generatorsNoneAST-level generatorsNone (BUILD files)
Module boundariesNot nativeESLint rule enforcementVisibility rules
PolyglotJS/TS focusedJS/TS + .NET, Java, Go, PythonAny language
IDE integrationNoneNx Console (VS Code, JetBrains)Bazel plugin
Team size3–2010–100100+

CI/CD optimization for monorepos

CI/CD is where monorepos most clearly show both their strengths and weaknesses. Without optimization, every PR triggers a full-repo build and CI is slower than polyrepo. Optimized correctly, you build only affected packages, and CI is many times faster.

Affected-only pipeline strategy

graph LR
    A[PR Created] --> B[Detect Changed Files]
    B --> C[Resolve Dependency Graph]
    C --> D[Identify Affected Packages]
    D --> E{Cache Hit?}
    E -->|Yes| F[Skip Build]
    E -->|No| G[Build + Test]
    G --> H[Upload to Remote Cache]
    F --> I[CI Pass]
    H --> I
    style A fill:#2c3e50,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style C fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style D fill:#e94560,stroke:#fff,color:#fff
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#4CAF50,stroke:#fff,color:#fff
    style G fill:#2c3e50,stroke:#fff,color:#fff
    style H fill:#2c3e50,stroke:#fff,color:#fff
    style I fill:#4CAF50,stroke:#fff,color:#fff

Optimized CI pipeline: only build affected packages, skip when the cache hits.

GitHub Actions with Turborepo

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2   # Need the previous commit to compare

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - run: pnpm turbo build test lint --filter=...[HEAD~1]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

--filter=...[HEAD~1] runs tasks only for packages that changed since the previous commit. Combined with remote cache, most tasks hit the cache — CI finishes in 1–2 minutes instead of 15–20.

GitHub Actions with Nx

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # Full history for affected detection

      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - uses: nrwl/nx-set-shas@v4

      - run: pnpm nx affected -t lint test build --parallel=3

Code ownership and governance

A monorepo merges code but doesn't merge accountability. Without clear ownership, changes go unreviewed and quality decays. That's why a CODEOWNERS file is essential in a monorepo.

# CODEOWNERS
/apps/web/           @team-frontend
/apps/api/           @team-backend
/packages/ui/        @team-design-system
/packages/auth/      @team-security
/packages/config/    @team-platform
*.yml                @team-devops

Anti-pattern: a shared package with no owner

When a shared package (say packages/utils) has no responsible team, anyone can drop functions into it without careful review. Over time it turns into a "junk drawer" of unrelated code. Always assign owners for every package, shared ones included.

Best practices for production monorepos

1. Start with pnpm Workspaces + Turborepo

Don't jump straight to Nx or Bazel. Start with the simplest combo: pnpm manages dependencies, Turborepo runs tasks. When the team or codebase grows to need module boundaries or distributed CI, migrate to Nx.

2. Clearly split apps/ and packages/

apps/ contains deployable applications (web, api, mobile). packages/ contains reusable libraries (ui, utils, config). Apps import packages, packages do not import apps. Simple rule, but highly effective at preventing circular dependencies.

3. Shared config at the root

TypeScript config, ESLint config, Prettier config — put them at the root and extend from child packages. Avoid copy-pasting config between packages.

// packages/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

4. Versioning strategy: fixed vs independent

StrategyDescriptionFits
Fixed (locked)All packages share one versionInternal packages, not published to npm
IndependentEach package versions independentlyPublished to npm, multiple consumers

Nx supports both via nx release. Turborepo pairs with changesets for versioning.

5. Git sparse checkout for developer experience

When the repo gets large enough that cloning takes many minutes, use sparse checkout to only fetch the code you need:

git clone --filter=blob:none --sparse https://github.com/myorg/monorepo.git
cd monorepo
git sparse-checkout set apps/web packages/ui packages/utils

6. Enforce conventional commits

With many teams committing to one repo, conventional commits (feat(auth):, fix(ui):, chore(ci):) automate changelogs and make it easy to filter history by scope.

Migration path: from polyrepo to monorepo

Phase 1: pilot (1–2 weeks)
Pick 2–3 tightly related repos (e.g., web app + shared UI). Merge into a monorepo with pnpm workspaces. Set up basic Turborepo. Run in parallel with the old polyrepo.
Phase 2: CI/CD (1 week)
Set up an affected-only pipeline on GitHub Actions. Enable remote caching. Benchmark CI time vs the old polyrepo — target ≥50% reduction.
Phase 3: team onboarding (1–2 weeks)
Migrate more repos. Set up CODEOWNERS. Document workspace conventions. Train the team on turbo/nx commands.
Phase 4: scale (ongoing)
Evaluate Nx if you need module boundaries or distributed CI. Implement code generators for standardized project scaffolding. Monitor CI metrics continuously.

Conclusion

Monorepo 2026 is no longer "only for big tech companies." With a mature tooling ecosystem — pnpm Workspaces for dependency management, Turborepo for task caching, Nx for workspace governance — any team of 5+ can benefit.

Start simple with pnpm + Turborepo. When you need architectural boundaries or CI is slow on a single machine, upgrade to Nx. Bazel only makes sense once your repo crosses hundreds of thousands of files across multiple languages.

The most important thing is not the tooling but the culture: clear code ownership, conventional commits, shared config, and continuously measured CI pipelines. Monorepo is only the vehicle — team discipline is the engine.

Summary: pick your setup

  • Small team (3–20), pure JS/TS: pnpm Workspaces + Turborepo
  • Medium team (10–100), governance needed: pnpm + Nx
  • Enterprise (100+), polyglot: Bazel or Nx + plugins

References: