← All articles
INFRASTRUCTURE Monorepo Tools Compared: Nx vs Turborepo vs Bazel vs... 2026-02-09 · 7 min read · monorepo · nx · turborepo

Monorepo Tools Compared: Nx vs Turborepo vs Bazel vs Lerna

Infrastructure 2026-02-09 · 7 min read monorepo nx turborepo bazel lerna build-tools infrastructure

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:

  1. Task orchestration: Running builds in the right order based on dependency graphs
  2. Caching: Not rebuilding packages that haven't changed
  3. Affected detection: Only testing/building what's actually impacted by a change
  4. Parallel execution: Running independent tasks concurrently
  5. 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:

Limitations:

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:

Limitations:

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:

Limitations:

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

Choose Nx When

Choose Bazel When

Choose Lerna When

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

  1. Apps can depend on packages, not other apps: apps/web can import from packages/ui, but not from apps/api
  2. Packages can depend on other packages: packages/ui can import from packages/utils
  3. Avoid circular dependencies: If package A imports from B, B cannot import from A
  4. 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:

  1. Set up the monorepo structure with your chosen tool
  2. Move one package at a time, starting with shared libraries
  3. Update import paths to use workspace references
  4. Set up CI/CD with affected detection from day one
  5. 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.