DEV Community

ZNY
ZNY

Posted on

Monorepos in 2026: Turborepo vs Nx vs Bazel — What Actually Works

Monorepos in 2026: What Actually Works

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:

  1. Atomic commits across services — Change an API contract and update all consumers in one PR
  2. Shared tooling — One ESLint config, one Prettier config, one TypeScript config
  3. Easy refactoring — Rename a function used across 20 packages without coordination
  4. Unified CI/CD — One pipeline that understands dependency relationships
  5. Developer experience — One git clone, one npm 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"
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The Cache That Actually Works

# First build (slow)
$ 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)

# Second build (instant - cache hit)
$ 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)
Enter fullscreen mode Exit fullscreen mode

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

# Create Nx workspace
npx create-nx-workspace@latest myorg --preset=ts

# Add a new application
npx nx generate @nx/nextjs:app admin

# Add a library
npx nx generate @nx/js:library api-client --directory=packages/api-client
Enter fullscreen mode Exit fullscreen mode

The Affected Command (Nx's Killer Feature)

# Only build/test/lint what changed (and what depends on what changed)
npx nx affected --target=build --base=origin/main

# This is the game-changer for large monorepos
# Instead of rebuilding everything, Nx computes the dependency graph
# and only rebuilds the affected packages
Enter fullscreen mode Exit fullscreen mode

Visualizing the Project Graph

# Opens browser with interactive dependency graph
npx nx graph
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Move packages one at a time

# Move one package
mv ../old-repo/packages/ui apps/ui
cd apps/ui
npm init -y
# Update package.json name to @myorg/ui
cd ../..
git add -A
git commit -m "feat: move ui package"
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Add shared configs

# packages/config-eslint/index.js
module.exports = {
  extends: ['next/core-web-vitals', 'turbo'],
  rules: {
    '@typescript-eslint/no-unused-vars': 'error'
  }
};

# packages/config-typescript/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

# .npmrc
save-exact=true
Enter fullscreen mode Exit fullscreen mode

The Big Git History Question

Do you keep git history when migrating?

# Option 1: Keep history (preserve all commit messages)
git subtree add --prefix=apps/web https://github.com/old/web.git main

# Option 2: Fresh start (cleaner, lose history)
mv old-repo/web apps/web
Enter fullscreen mode Exit fullscreen mode

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

# GitHub Actions with Turborepo
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...
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)