Monorepo 2026: Turborepo, Nx, and pnpm Workspaces — Managing Code for Large Teams
Posted on: 4/21/2026 4:10:45 AM
Table of contents
- Monorepo vs Polyrepo: the real trade-off
- pnpm Workspaces — the dependency-management foundation
- Turborepo — a high-speed task runner
- Nx — a complete monorepo management ecosystem
- Bazel — the tech-giants' weapon
- Side-by-side: Turborepo vs Nx vs Bazel
- CI/CD optimization for monorepos
- Code ownership and governance
- Best practices for production monorepos
- Migration path: from polyrepo to monorepo
- Conclusion
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.
| Criterion | Monorepo | Polyrepo |
|---|---|---|
| Dependency sync | Single lockfile, versions always aligned | Separate lockfiles, drift is easy |
| Code sharing | Import directly, no publishing needed | Must publish to a registry and then install |
| Atomic changes | One PR can change both API and frontend | Must sync multiple PRs across repos |
| CI/CD complexity | Needs affected detection and caching | Simple: one pipeline per repo |
| Git performance | Needs sparse checkout at scale | Each repo stays light |
| Team autonomy | Module boundaries + CODEOWNERS | Fully 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:
| Setup | Turborepo | Nx |
|---|---|---|
| Single-machine CI | 25m 32s | 21m 56s |
| Distributed (4 machines) | Not supported natively | 9m 20s |
| Improvement vs single | — | 57% 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
| Criterion | Turborepo | Nx | Bazel |
|---|---|---|---|
| Setup time | < 30 minutes | 1–2 hours | 1–2 weeks |
| Learning curve | Low | Moderate | Very high |
| Task caching | Content-aware hashing | Named inputs, ing | Action-level, hermetic |
| Remote caching | Vercel or self-hosted | Nx Cloud or self-hosted | Remote Build Execution |
| Affected detection | Filter syntax | Graph-based, file-level | Fine-grained, ~200ms |
| Distributed CI | Not native | Nx Agents | Remote Execution |
| Code generators | None | AST-level generators | None (BUILD files) |
| Module boundaries | Not native | ESLint rule enforcement | Visibility rules |
| Polyglot | JS/TS focused | JS/TS + .NET, Java, Go, Python | Any language |
| IDE integration | None | Nx Console (VS Code, JetBrains) | Bazel plugin |
| Team size | 3–20 | 10–100 | 100+ |
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
| Strategy | Description | Fits |
|---|---|---|
| Fixed (locked) | All packages share one version | Internal packages, not published to npm |
| Independent | Each package versions independently | Published 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
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:
OpenTelemetry — The Observability Standard for Distributed Systems
Saga Pattern: Managing Distributed Transactions in Microservices
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.