Monorepo Tools Compared: Nx vs Turborepo vs Bazel vs Lerna
Monorepo Tools Compared: Nx vs Turborepo vs Bazel vs Lerna
A monorepo is only as good as the tooling that manages it. Without proper build orchestration, a monorepo with 50 packages becomes slower than 50 separate repos. The right tool makes builds fast through caching and parallelization, keeps dependency graphs sane, and scales as your codebase grows.
Why Monorepo Tooling Matters
The core problems monorepo tools solve:
- Task orchestration: Running builds in the right order based on dependency graphs
- Caching: Not rebuilding packages that haven't changed
- Affected detection: Only testing/building what's actually impacted by a change
- Parallel execution: Running independent tasks concurrently
- Dependency management: Keeping package versions consistent
Without tooling, npm run build in a monorepo rebuilds everything. With tooling, it rebuilds only what changed -- often reducing CI times from 30 minutes to 2 minutes.
Tool Comparison at a Glance
| Feature | Nx | Turborepo | Bazel | Lerna (v7+) |
|---|---|---|---|---|
| Language | TypeScript | Go + Rust | Java + Starlark | TypeScript |
| Local caching | Yes | Yes | Yes | Yes (via Nx) |
| Remote caching | Yes (Nx Cloud) | Yes (Vercel) | Yes (built-in) | Yes (via Nx Cloud) |
| Task orchestration | Yes | Yes | Yes | Yes |
| Affected detection | Yes (advanced) | Hash-based | Exact (action graph) | Yes (via Nx) |
| Code generation | Yes (extensive) | No | No | No |
| Dependency graph viz | Yes | Yes (basic) | Yes | Yes (via Nx) |
| Language support | Primarily JS/TS | Primarily JS/TS | Any language | JS/TS only |
| Learning curve | Medium-high | Low | Very high | Low |
| Best for | Large JS/TS monorepos | Simple-to-medium monorepos | Massive polyglot repos | Legacy Lerna projects |
Turborepo: The Simple Choice
Turborepo is the most approachable monorepo tool. It does one thing well: fast task execution with caching. If your needs are straightforward, start here.
Setup
# New monorepo
npx create-turbo@latest
# Add to existing monorepo
npm install turbo --save-dev
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
The ^build syntax means "run build in all dependencies first." This ensures packages are built in the correct topological order.
Running Tasks
# Build everything (with caching)
turbo build
# Build only packages affected by changes
turbo build --filter=...[HEAD~1]
# Build a specific package and its dependencies
turbo build --filter=@myorg/web
# Run dev servers for specific packages
turbo dev --filter=@myorg/web --filter=@myorg/api
# Dry run to see what would execute
turbo build --dry-run
Remote Caching with Vercel
# Link to Vercel for remote caching
turbo login
turbo link
# Now CI and other developers share the same cache
# If someone already built @myorg/utils, you get the cached output
Turborepo Strengths and Limitations
Strengths:
- Minimal configuration -- works well out of the box
- Fast task execution (written in Go and Rust)
- Simple mental model -- it's just a task runner with caching
- Excellent Vercel integration for remote caching
Limitations:
- No code generation
- No dependency graph analysis beyond tasks
- Limited plugin ecosystem
- No built-in migration or upgrade tooling
- Filtering syntax is less powerful than Nx
Nx: The Full-Featured Platform
Nx goes beyond task orchestration. It's an entire development platform with code generators, dependency graph visualization, and deep framework integrations. This power comes with more complexity.
Setup
# New Nx workspace
npx create-nx-workspace@latest myorg
# Add Nx to an existing monorepo
npx nx init
// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true,
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"cache": true,
"inputs": ["default", "^production"]
},
"lint": {
"cache": true
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/tsconfig.spec.json"
]
}
}
Project Configuration
// apps/web/project.json
{
"name": "web",
"targets": {
"build": {
"executor": "@nx/vite:build",
"options": {
"outputPath": "dist/apps/web"
}
},
"serve": {
"executor": "@nx/vite:dev-server"
},
"test": {
"executor": "@nx/vite:test"
}
}
}
Code Generation
Nx's generators are a major differentiator. They scaffold code that follows best practices:
# Generate a new React application
nx generate @nx/react:app my-app
# Generate a shared library
nx generate @nx/react:lib shared-ui
# Generate a component in the shared library
nx generate @nx/react:component Button --project=shared-ui
# Generate a custom workspace generator
nx generate @nx/plugin:generator my-generator --project=tools
Dependency Graph Visualization
# Open interactive dependency graph in browser
nx graph
# Show what's affected by changes
nx affected:graph
Running Tasks
# Build everything
nx run-many -t build
# Build only affected projects
nx affected -t build
# Run a specific project's target
nx build web
# Run multiple targets
nx run-many -t build test lint
Nx Strengths and Limitations
Strengths:
- Comprehensive code generators for many frameworks
- Advanced affected detection with fine-grained inputs
- Interactive dependency graph visualization
- Module boundary enforcement (prevent bad imports)
- Plugin ecosystem with deep framework integrations
- Migration generators for Nx version upgrades
Limitations:
- Steeper learning curve than Turborepo
- Opinionated project structure
- Configuration can be verbose
- Some features require Nx Cloud (paid) for full benefit
- Can feel heavyweight for small monorepos
Bazel: Enterprise-Grade Build System
Bazel (created by Google) is the most powerful build system on this list. It supports any language, has hermetic builds (guaranteed reproducibility), and scales to millions of lines of code. The trade-off is significant complexity.
Setup
# WORKSPACE.bazel (root configuration)
workspace(name = "myorg")
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
# Load Node.js rules
http_archive(
name = "build_bazel_rules_nodejs",
sha256 = "...",
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/..."],
)
# apps/web/BUILD.bazel
load("@npm//:defs.bzl", "npm_link_all_packages")
load("//tools:defs.bzl", "ts_project", "vite_build")
npm_link_all_packages()
ts_project(
name = "web_lib",
srcs = glob(["src/**/*.ts", "src/**/*.tsx"]),
deps = [
"//packages/shared-ui",
"//:node_modules/react",
"//:node_modules/react-dom",
],
)
vite_build(
name = "web",
srcs = [":web_lib"],
config = "vite.config.ts",
)
Running Tasks
# Build a target
bazel build //apps/web:web
# Test everything
bazel test //...
# Query the dependency graph
bazel query 'deps(//apps/web:web)'
# Find what's affected by a change
bazel query 'rdeps(//..., //packages/shared-ui:shared-ui)'
Bazel Strengths and Limitations
Strengths:
- True hermeticity -- builds are bit-for-bit reproducible
- Language agnostic -- supports JS, Python, Go, Java, C++, Rust, and more
- Massive scale -- proven at Google, Meta, and Stripe
- Fine-grained caching at the action level (not just the task level)
- Remote execution -- distribute builds across a cluster
Limitations:
- Very steep learning curve (Starlark language, BUILD files)
- Significant setup and maintenance effort
- Poor IDE integration compared to Nx/Turborepo
- Ecosystem is smaller and more enterprise-focused
- Overkill for most JavaScript-only monorepos
Lerna (v7+): The Legacy Option
Lerna was the original JavaScript monorepo tool. After being acquired by Nx in 2022, it was revitalized and now uses Nx under the hood for task execution and caching. For existing Lerna projects, upgrading to v7+ gets you Nx's performance without migration.
// lerna.json
{
"version": "independent",
"npmClient": "npm",
"useNx": true,
"command": {
"publish": {
"message": "chore(release): publish"
}
}
}
# Run builds with Nx caching
lerna run build
# Publish packages
lerna publish
# Version packages
lerna version
Lerna's main remaining value is package publishing workflows -- versioning and publishing multiple npm packages from a monorepo. For everything else, use Nx or Turborepo directly.
Package Manager Workspaces
Before reaching for a monorepo tool, make sure you're using your package manager's workspace support. This is the foundation everything else builds on.
npm Workspaces
// package.json (root)
{
"workspaces": ["apps/*", "packages/*"]
}
pnpm Workspaces
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Bun Workspaces
// package.json (root)
{
"workspaces": ["apps/*", "packages/*"]
}
pnpm workspaces are generally recommended for monorepos because of strict dependency isolation (each package only accesses its declared dependencies, unlike npm's hoisting).
Decision Framework
Choose Turborepo When
- You're starting a new JavaScript/TypeScript monorepo
- Your team values simplicity over features
- You want fast task execution with minimal configuration
- You're already using Vercel for deployment
- You have fewer than 50 packages
Choose Nx When
- You need code generation and scaffolding
- You want to enforce module boundaries (package import rules)
- Your monorepo has many packages with complex relationships
- You need deep framework integration (Next.js, Angular, React)
- You want advanced affected detection with fine-grained inputs
Choose Bazel When
- Your monorepo contains multiple languages (not just JavaScript)
- You need hermetic, reproducible builds
- You have hundreds of engineers or millions of lines of code
- You need distributed build execution
- Your organization can invest in build engineering expertise
Choose Lerna When
- You have an existing Lerna monorepo and don't want to migrate
- Your primary need is npm package publishing
- You want Nx performance with Lerna's publishing workflows
Monorepo Structure Best Practices
Regardless of which tool you choose, these structural patterns help:
monorepo/
├── apps/ # Deployable applications
│ ├── web/ # Frontend app
│ ├── api/ # Backend API
│ └── admin/ # Admin dashboard
├── packages/ # Shared libraries
│ ├── ui/ # Shared UI components
│ ├── config/ # Shared configuration (ESLint, TypeScript)
│ ├── utils/ # Shared utility functions
│ └── types/ # Shared TypeScript types
├── tools/ # Build tools, scripts, generators
├── turbo.json # or nx.json / WORKSPACE.bazel
├── package.json
└── tsconfig.base.json # Shared TypeScript config
Package Dependency Rules
- Apps can depend on packages, not other apps:
apps/webcan import frompackages/ui, but not fromapps/api - Packages can depend on other packages:
packages/uican import frompackages/utils - Avoid circular dependencies: If package A imports from B, B cannot import from A
- Keep shared packages small: A package with 100 exports that everyone imports defeats the purpose of caching
// Nx module boundary enforcement -- .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:lib"] },
{ "sourceTag": "type:lib", "onlyDependOnLibsWithTags": ["type:lib"] },
{ "sourceTag": "scope:web", "onlyDependOnLibsWithTags": ["scope:shared", "scope:web"] }
]
}
]
}
}
Migration: Moving to a Monorepo
If you're consolidating multiple repos, here's the approach:
- Set up the monorepo structure with your chosen tool
- Move one package at a time, starting with shared libraries
- Update import paths to use workspace references
- Set up CI/CD with affected detection from day one
- Enable remote caching before the monorepo gets large
# Moving a repo into a monorepo while preserving git history
git subtree add --prefix=packages/my-lib [email protected]:org/my-lib.git main --squash
Summary
For most JavaScript/TypeScript teams, the choice is between Turborepo and Nx. Start with Turborepo if you want simplicity and don't need code generation. Choose Nx when you need the full platform experience with generators, module boundaries, and advanced affected detection. Bazel is reserved for large polyglot organizations willing to invest in build engineering. And if you're on Lerna, just upgrade to v7+ to get Nx's engine without the migration effort.
The most important thing is picking a tool early. A monorepo without proper tooling becomes a burden fast, and retrofitting tooling onto a large monorepo is significantly harder than starting with it.