The monorepo debate in 2026 has settled into something more mature. Turborepo became the default for most JS/TS teams, Nx found its niche in enterprises with complex dependency graphs, and Bazel still dominates at Google scale. Here's what I've learned from running all three in production.
Why Monorepos Won #
Let's be clear about why monorepos became the default in 2026:
Atomic commits across services— Change an API contract and update all consumers in one PR -
Shared tooling— One ESLint config, one Prettier config, one TypeScript config -
Easy refactoring— Rename a function used across 20 packages without coordination -
Unified CI/CD— One pipeline that understands dependency relationships -
Developer experience— Onegit clone
, onenpm install
, everything works
The alternative — polyrepo hell where a simple rename requires 15 PRs across 15 repos — killed productivity. Teams migrated.
Turborepo: The Default Choice #
Turborepo won because it has sensible defaults, a gentle learning curve, and just works for most JavaScript/TypeScript projects. It's not the most powerful, but it's the most practical.
Setting Up a Turborepo Project in 2026
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0"
}
}
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
}
}
}
Real Package Dependencies
apps/
web/ → depends on @repo/ui, @repo/api-client
admin/ → depends on @repo/ui, @repo/api-client
docs/ → depends on @repo/ui, @repo/docs-utils
packages/
ui/ → no internal deps
api-client/ → no internal deps
docs-utils/ → no internal deps
config/ → no internal deps (ESLint, TypeScript configs)
The Cache That Actually Works
$ turbo build
• building web
• building admin
• building docs
• building ui
• building api-client
• building docs-utils
→ Tasks run by turbo: 6 (64ms each, 384ms total)
$ turbo build
• building web (cache hit)
• building admin (cache hit)
• building docs (cache hit)
• building ui (cache hit)
• building api-client (cache hit)
• building docs-utils (cache hit)
→ Tasks run by turbo: 0 (6 in cache, 12ms total)
Nx: When You Need the Power #
Nx is Turborepo on steroids. It understands your project graph at a deeper level, has built-in code generators, affected commands, and powerful visualization tools. The tradeoff: more complexity, steeper learning curve.
Nx Project Setup
npx create-nx-workspace@latest myorg --preset=ts
npx nx generate @nx/nextjs:app admin
npx nx generate @nx/js:library api-client --directory=packages/api-client
The Affected Command (Nx's Killer Feature)
npx nx affected --target=build --base=origin/main
Visualizing the Project Graph
npx nx graph
This generates a visual graph of your entire project. You can see exactly which packages depend on which, spot circular dependencies, and understand the impact of proposed changes.
Nx Computation Cache (Distributed)
// nx.json
{
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["!{projectRoot}/**/*.spec.ts"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["production", "^production"]
}
},
"nxCloudAccessToken": "your-nx-cloud-token"
}
Nx Cloud provides distributed caching — your CI server and local machine share the same build cache. Cold CI builds become warm builds instantly.
Bazel: At Google Scale #
Bazel is the nuclear option. It's infinitely scalable, hermetically sealed (reproducible builds), and can handle truly massive codebases. The tradeoff: significant operational overhead, Starlark learning curve, and ecosystem fragmentation outside the Google ecosystem.
When Bazel Makes Sense
- 1,000+ engineers in a single codebase
- Multiple languages (TypeScript + Python + Go + Rust + Java)
- Need per-language optimized tooling
- hermetic builds are a hard requirement
- You're okay with dedicated build infrastructure team
For most teams, Bazel is overkill. The rule of thumb: if you're asking whether you need Bazel, you don't.
The Migration Path #
From Polyrepo to Turborepo
Step 1: Create the monorepo structure
mkdir my-monorepo && cd my-monorepo
git init
npm init -y
npm install -D turbo
mkdir apps packages
Step 2: Move packages one at a time
mv ../old-repo/packages/ui apps/ui
cd apps/ui
npm init -y
cd ../..
git add -A
git commit -m "feat: move ui package"
Step 3: Fix dependency issues
// Before (old package.json)
{
"name": "ui",
"dependencies": {
"react": "^18.0.0"
}
}
// After (in monorepo)
{
"name": "@myorg/ui",
"peerDependencies": {
"react": "^18.0.0"
}
}
Step 4: Add shared configs
module.exports = {
extends: ['next/core-web-vitals', 'turbo'],
rules: {
'@typescript-eslint/no-unused-vars': 'error'
}
};
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
The Hidden Costs Nobody Warns You About #
Build Tool Lockstep
When you share a config
package across 20 apps, updating TypeScript becomes a coordinated event. You can't have App A on TypeScript 5.3 and App B on 5.4 while they're in the same repo.
Solution: Use pnpm workspaces
with version pinning:
packages:
- 'apps/*'
- 'packages/*'
save-exact=true
The Big Git History Question
Do you keep git history when migrating?
git subtree add --prefix=apps/web https://github.com/old/web.git main
mv old-repo/web apps/web
I recommend Option 2 for most migrations. Git history for individual packages in a monorepo is rarely useful, and the migration complexity isn't worth it.
CI/CD Complexity
name: CI
on: [push, pull_request]
jobs:
affected:
name: Check affected
runs-on: ubuntu-latest
outputs:
affected: ${{ steps.turbo.outputs.affected }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- id: turbo
run: echo "affected=$(pnpm turbo run build --dry-run=json | jq -r '.packages | if length > 0 then "true" else "false" end')" >> $GITHUB_OUTPUT
build:
needs: affected
if: needs.affected.outputs.affected == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build --filter=...affected packages...
2026 Tooling Ecosystem #
| Tool | Language Agnostic | Learning Curve | Scale | Best For |
|---|---|---|---|---|
| Turborepo | JS/TS mostly | Gentle | < 100 packages | Most teams |
| Nx | Multiple | Medium | < 500 packages | Enterprises |
| Bazel | Any | Steep | Unlimited | Google-scale |
| Lerna | JS/TS | Gentle | < 50 packages | Legacy (deprecated) |
My Recommendation #
For most teams in 2026: Start with Turborepo. It's the right default. You get 80% of the benefit for 20% of the complexity.
If you're in an enterprise with complex dependencies, code generators, and a dedicated platform team: Nx is worth the investment.
If you're at Google scale: Bazel. But you already knew that.
The worst choice: using no monorepo tooling at all. Running a monorepo with just npm workspaces
and manual dependency tracking is technical debt that compounds.
Running a monorepo? What's your tool of choice in 2026? Let's hear real-world experiences.
Further reading: Turborepo docs, Nx docs, Bazel migration guide