Skip to content
Published on

Monorepo Strategy Guide 2025: Nx vs Turborepo vs Lerna — Complete Guide to Managing Large Codebases

Authors

Introduction: Why Monorepo

Google manages over 2 billion lines of code in a single repository. Meta, Microsoft, Uber, and Airbnb also use monorepos. Monorepos are no longer an experimental strategy but a proven pattern for large-scale software development.

However, poorly managed monorepos lead to CI taking over 30 minutes, dependency hell, and unclear code ownership. This article covers everything from proper adoption strategies to in-depth Nx, Turborepo, and Lerna comparisons, CI/CD optimization, and team management.


1. Monorepo vs Polyrepo

1.1 Definitions

  • Monorepo: Managing multiple projects/packages in a single repository
  • Polyrepo: Managing each project in a separate repository (also called multi-repo)

1.2 Comparison Table

AspectMonorepoPolyrepo
Code sharingImmediate (same repo)Via npm/registry
Dependency managementUnifiedIndependent per repo
Atomic changesPossible (single PR)Impossible (multiple PRs)
CI complexityHigh (optimization needed)Low (independent repos)
Code visibilitySearchable across all codeSeparate search per repo
Release managementComplex (Changesets etc.)Simple (individual versions)
Permission managementCODEOWNERS neededSeparated by repo
Initial cloneCan be slowFast
RefactoringPossible across entire codebaseDifficult at repo boundaries

1.3 When to Choose a Monorepo

Monorepo is suitable when:

  • There are tight dependencies between packages
  • Many shared libraries exist
  • Atomic changes are needed
  • You want to maximize code reuse
  • Cross-team code visibility is important

Polyrepo is suitable when:

  • Projects are completely independent
  • Teams use very different tech stacks
  • Strict access control is required
  • Open-source and private code must be separated

2. Monorepo at Scale

2.1 Google (Piper)

  • Scale: Over 2 billion lines, 86TB
  • Tools: Piper (custom VCS) + Blaze/Bazel (build)
  • Key insight: All code in one repository, trunk-based development
  • Lesson: Impossible without proper tooling. Custom VCS and build systems are essential

2.2 Meta (Buck2)

  • Tools: Mercurial + Buck2 (build system)
  • Key insight: Virtual filesystem loads only needed files
  • Lesson: At massive scale, filesystem-level optimization is necessary

2.3 Microsoft (1JS)

  • Tools: Git + Rush (build orchestration)
  • Key insight: Unified management of JavaScript/TypeScript projects
  • Lesson: Incremental migration is the realistic strategy

2.4 Uber

  • Tools: Go monorepo + Buck (build)
  • Key insight: Over 5000 microservices in a single repository
  • Lesson: Microservices and monorepo can coexist

3. Tool Comparison: Nx vs Turborepo vs Lerna vs Rush

3.1 Feature Comparison Table

FeatureNxTurborepoLernaRush
Task pipelineYesYesYes (v7+)Yes
Local cachingYesYesNoYes
Remote cachingYes (Nx Cloud)Yes (Vercel)NoYes (self-hosted)
Affected detectionYes (project graph)Yes (file hashing)Yes (v7+)Yes
Code generatorsYes (generators)NoNoNo
Project graph visualizationYesNoNoNo
Framework pluginsYes (React, Angular etc.)NoNoNo
Distributed executionYes (Nx Agents)NoNoYes
Package managersnpm, yarn, pnpmnpm, yarn, pnpmnpm, yarn, pnpmpnpm
Learning curveHighLowLowMedium
Open sourceYesYesYesYes

3.2 Tool Selection Guide

Starting a project?
├── Small (fewer than 10 packages)
│   ├── Want quick start → Turborepo
│   └── Need code generation → Nx
├── Medium (10-50 packages)
│   ├── Vercel/Next.js ecosystem → Turborepo
│   ├── Need rich plugins → Nx
│   └── Already using LernaLerna v7
└── Large (50+ packages)
    ├── Need distributed execution → Nx
    └── Microsoft-style → Rush

4. pnpm Workspace Setup

4.1 Basic Structure

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── .npmrc
├── apps/
│   ├── web/
│   │   └── package.json
│   └── api/
│       └── package.json
├── packages/
│   ├── ui/
│   │   └── package.json
│   ├── utils/
│   │   └── package.json
│   └── config/
│       └── package.json
└── tooling/
    ├── eslint/
    │   └── package.json
    └── typescript/
        └── package.json

4.2 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"
  - "tooling/*"

4.3 Root package.json

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "clean": "turbo run clean"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  },
  "packageManager": "pnpm@9.0.0"
}

4.4 Cross-Package References

{
  "name": "@myorg/web",
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:*"
  }
}

workspace:* directly references local packages. When publishing to npm, pnpm replaces it with the actual version.

4.5 .npmrc Configuration

# Hoisting settings
shamefully-hoist=false
strict-peer-dependencies=false

# Workspace settings
link-workspace-packages=true
prefer-workspace-packages=true

5. Nx Deep Dive

5.1 Nx Initial Setup

# Create new Nx workspace
npx create-nx-workspace@latest my-monorepo --preset=ts

# Add Nx to existing monorepo
npx nx@latest init

5.2 nx.json Configuration

{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "cache": true
    },
    "test": {
      "inputs": ["default", "^production"],
      "cache": true
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
      "cache": true
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/tsconfig.spec.json"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "nxCloudAccessToken": "your-token-here"
}

5.3 Project Graph

# Visualize project dependency graph
npx nx graph

# Focus on a specific project
npx nx graph --focus=my-app

# View affected projects
npx nx affected:graph
Project graph example:

    web-app ───▶ ui-lib ───▶ utils
       │            │
       ▼            ▼
    api-app ───▶ shared-types

5.4 Affected Commands (Change Detection)

# Build only projects affected by changes
npx nx affected -t build

# Test only affected projects
npx nx affected -t test

# Specify base branch
npx nx affected -t build --base=main --head=HEAD

How Affected works:

  1. Detect changed files via Git diff
  2. Find projects containing those files in the project graph
  3. Trace the dependency graph to identify all affected projects
  4. Run tasks only for those projects

5.5 Generators (Code Generation)

# Generate React component
npx nx generate @nx/react:component Button --project=ui

# Generate library
npx nx generate @nx/js:library shared-utils

# Create custom generator
npx nx generate @nx/plugin:generator my-generator --project=tools

5.6 Nx Cloud (Remote Caching + Distributed Execution)

# Connect to Nx Cloud
npx nx connect
# CI configuration with Nx
name: CI
on: [push]
jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
      - run: pnpm install --frozen-lockfile
      - uses: nrwl/nx-set-shas@v4
      - run: npx nx affected -t lint test build

6. Turborepo Deep Dive

6.1 Turborepo Initial Setup

# Create new Turborepo project
npx create-turbo@latest

# Add Turborepo to existing monorepo
pnpm add -D turbo -w

6.2 turbo.json Configuration

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "env": ["DATABASE_URL"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["$TURBO_DEFAULT$"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "clean": {
      "cache": false
    }
  }
}

6.3 How Task Pipelines Work

When running turbo run build:

1. Dependency graph analysis
   utils (no deps)ui (depends on utils)web (depends on ui, utils)

2. Parallel execution
   [utils: build] ──done──▶ [ui: build] ──done──▶ [web: build]
                            [api: build] ──────────┘
                              (depends only on utils)

3. Caching
   - Calculate hash of input files
   - If hash matches previous build, restore from cache
   - Build time: 5min → 0.1sec (cache hit)

6.4 Caching Mechanism

# Local cache location
ls node_modules/.cache/turbo/

# Check cache status
turbo run build --dry-run

# Invalidate cache
turbo run build --force

# Enable remote caching
npx turbo login
npx turbo link

6.5 Filtering and Scoping

# Build specific package only
turbo run build --filter=@myorg/web

# Build only changed packages
turbo run build --filter=...[HEAD^1]

# Build specific package and its dependencies
turbo run build --filter=@myorg/web...

# Packages in a specific directory
turbo run build --filter=./apps/*

6.6 Remote Caching Setup

# Vercel Remote Cache (official)
npx turbo login
npx turbo link

Environment variable configuration:

TURBO_TOKEN=your-token
TURBO_TEAM=your-team
TURBO_API=https://your-cache-server.com

7. Shared Library Strategies

7.1 Package Classification

packages/
├── ui/              # UI components (Button, Modal, Form)
├── utils/           # Utilities (date, string, validation)
├── types/           # Shared TypeScript types
├── config/          # Shared config (ESLint, TypeScript, Prettier)
├── hooks/           # Shared React hooks
├── api-client/      # API client (type-safe fetch)
└── constants/       # Constants (error codes, route paths)

7.2 Internal Package Pattern

Pattern that directly references TypeScript source without building:

{
  "name": "@myorg/ui",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./*": "./src/*.ts"
  }
}

Set up path aliases in the consuming app's tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@myorg/ui": ["../../packages/ui/src"],
      "@myorg/ui/*": ["../../packages/ui/src/*"]
    }
  }
}

7.3 Built Package Pattern

Packages published to npm require separate builds:

{
  "name": "@myorg/utils",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts"
  }
}

8. Versioning and Changesets

8.1 Introduction to Changesets

Changesets automates versioning and changelog generation in monorepos.

# Installation
pnpm add -D @changesets/cli -w

# Initialize
pnpm changeset init

8.2 Workflow

# 1. Add change description
pnpm changeset
# ? Which packages changed? → @myorg/ui, @myorg/utils
# ? Change type? → minor (new feature)
# ? Description? → Added new Button variant

# 2. File created in .changeset/ directory
cat .changeset/brave-dogs-run.md
# ---
# "@myorg/ui": minor
# "@myorg/utils": patch
# ---
#
# Added new Button variant

# 3. Version update (run in CI)
pnpm changeset version

# 4. Publish
pnpm changeset publish

8.3 Configuration (.changeset/config.json)

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [["@myorg/ui", "@myorg/hooks"]],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@myorg/web", "@myorg/api"]
}
  • linked: Package groups whose versions are bumped together
  • fixed: Package groups that always share the same version
  • ignore: Packages excluded from publishing (apps, etc.)

8.4 Independent vs Fixed Versioning

StrategyDescriptionExample
IndependentEach package has its own versionui@2.3.0, utils@1.5.0
FixedAll packages share the same versionui@3.0.0, utils@3.0.0
LinkedRelated packages version togetherui@2.3.0 bump triggers hooks@2.3.0

9. CI/CD Optimization

9.1 Affected Build Strategy

Only build and test projects affected by changed files.

# .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: 0

      - uses: pnpm/action-setup@v2
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      # Turborepo: build only changed packages
      - run: turbo run build test lint --filter=...[origin/main]

      # Or Nx: affected projects only
      # - run: npx nx affected -t build test lint

9.2 CI Time Reduction with Remote Caching

Without cache:
  lint (2min) + test (5min) + build (8min) = 15min

Remote cache hit:
  lint (0.1s) + test (0.2s) + build (0.3s) = 0.6s

Only actually changed packages:
  lint (20s) + test (1min) + build (2min) = 3min 20s

9.3 Parallel Execution Strategy

# Matrix strategy for parallel execution
jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.packages }}
    steps:
      - uses: actions/checkout@v4
      - id: filter
        run: echo "packages=$(turbo run build --filter=...[origin/main] --dry-run=json | jq -c '.packages')" >> $GITHUB_OUTPUT

  build:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJson(needs.detect.outputs.packages) }}
    steps:
      - uses: actions/checkout@v4
      - run: turbo run build --filter=${{ matrix.package }}

9.4 Docker Build Optimization

# Docker build in a monorepo
FROM node:20-slim AS base
RUN corepack enable

FROM base AS pruned
WORKDIR /app
COPY . .
# turbo prune: extract specific app and its dependencies
RUN npx turbo prune @myorg/api --docker

FROM base AS installer
WORKDIR /app
# Install dependencies first (cache layer)
COPY --from=pruned /app/out/json/ .
COPY --from=pruned /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile

# Copy source and build
COPY --from=pruned /app/out/full/ .
RUN pnpm turbo run build --filter=@myorg/api

FROM base AS runner
WORKDIR /app
COPY --from=installer /app/apps/api/dist ./dist
COPY --from=installer /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]

10. CODEOWNERS and Team Boundaries

10.1 CODEOWNERS File

# .github/CODEOWNERS

# Global default owners
* @org/platform-team

# App-specific owners
/apps/web/          @org/frontend-team
/apps/api/          @org/backend-team
/apps/mobile/       @org/mobile-team

# Package-specific owners
/packages/ui/       @org/design-system-team
/packages/utils/    @org/platform-team
/packages/auth/     @org/security-team

# Configuration files
/tooling/           @org/dx-team
/.github/           @org/dx-team
/turbo.json         @org/dx-team

10.2 Team Boundaries with Nx Module Boundaries

// .eslintrc.json
{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "depConstraints": [
          {
            "sourceTag": "scope:web",
            "onlyDependOnLibsWithTags": ["scope:shared", "scope:web"]
          },
          {
            "sourceTag": "scope:api",
            "onlyDependOnLibsWithTags": ["scope:shared", "scope:api"]
          },
          {
            "sourceTag": "type:app",
            "onlyDependOnLibsWithTags": ["type:lib", "type:util"]
          },
          {
            "sourceTag": "type:lib",
            "onlyDependOnLibsWithTags": ["type:lib", "type:util"]
          }
        ]
      }
    ]
  }
}

11. Migration from Polyrepo to Monorepo

11.1 Step-by-Step Migration

Step 1: Preparation

# Create new monorepo repository
mkdir my-monorepo && cd my-monorepo
git init
pnpm init

Step 2: Move with Git History Preserved

# Move existing repo as subdirectory (preserving history)
git remote add -f web-repo https://github.com/org/web-app.git
git merge web-repo/main --allow-unrelated-histories

# Restructure directory using git-filter-repo
git filter-repo --to-subdirectory-filter apps/web

Step 3: Workspace Configuration

# Set up pnpm workspace
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - "apps/*"
  - "packages/*"
EOF

Step 4: Extract Shared Packages

# Extract duplicate code into shared packages
mkdir -p packages/shared-utils/src
# Move common utilities and configure packages

Step 5: Update CI/CD

# Set up Turborepo or Nx
pnpm add -D turbo -w
# Configure turbo.json (see section 6.2)

11.2 Incremental Migration Strategy

Do not move all repositories at once. Recommended order:

  1. Move shared libraries first
  2. Move the most heavily depended-upon app
  3. Move remaining apps sequentially
  4. Validate CI/CD at each step

12. Common Pitfalls and Solutions

12.1 Slow CI

Problem: Building/testing all packages every time, CI takes over 30 minutes

Solution:

  • Use affected commands (build only what changed)
  • Enable remote caching
  • Configure parallel execution
# Before: build all packages (15min)
pnpm -r run build

# After: only changed packages (2min)
turbo run build --filter=...[origin/main]

12.2 Dependency Hell

Problem: Package A uses lodash@4 while Package B uses lodash@3

Solution:

  • Use pnpm's strict mode (prevents phantom dependencies)
  • Manage shared dependencies at the root
  • Use syncpack to synchronize versions
# Check dependency version mismatches with syncpack
npx syncpack list-mismatches
npx syncpack fix-mismatches

12.3 Unclear Code Ownership

Problem: Unclear who is responsible for which code

Solution:

  • Set up CODEOWNERS file (see section 10.1)
  • Enforce dependency restrictions with Nx module boundaries
  • Use team-specific package namespaces

12.4 Initial Clone Time

Problem: git clone of a large monorepo takes over 10 minutes

Solution:

# Shallow clone
git clone --depth 1 https://github.com/org/monorepo.git

# Partial clone (without blobs)
git clone --filter=blob:none https://github.com/org/monorepo.git

# Sparse checkout (specific directories only)
git clone --sparse https://github.com/org/monorepo.git
cd monorepo
git sparse-checkout set apps/web packages/ui

12.5 Build Order Issues

Problem: Package A depends on Package B, but B has not been built yet

Solution: Configure dependsOn in the task pipeline

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

^build means run the build task of the current package's dependencies first.


13. Interview Questions (10 Questions)

Q1. Explain the differences, advantages, and disadvantages of monorepo vs polyrepo.

Model answer: Monorepo manages multiple projects in a single repository, enabling easy code sharing and atomic changes, but requires CI optimization. Polyrepo manages each project in an independent repository, providing high independence but making code sharing difficult and cross-repository changes complex.

Q2. What are the key differences between Nx and Turborepo?

Model answer: Nx provides rich features including project graph visualization, code generators, framework plugins, and distributed execution, but has a steep learning curve. Turborepo focuses on caching and task pipelines with simple configuration, but lacks code generation and visualization features.

Q3. Explain how affected commands work.

Model answer: Changed files are detected via Git diff, then the project graph identifies which projects contain those files and all projects that depend on them. Only those projects are built/tested. This can reduce CI time by over 90%.

Q4. How does remote caching improve CI performance?

Model answer: Build inputs (source code, configuration, etc.) are hashed, and if the inputs are identical, previous build results are restored from a cloud cache. When multiple developers build the same code or CI runs repeated builds, the build can be skipped entirely.

Q5. What is the workspace:* protocol in pnpm workspaces?

Model answer: workspace:* is a protocol that directly references packages in the local workspace. They are connected via symlinks, so source changes are reflected immediately without building. When publishing to npm, pnpm automatically replaces it with the actual version number.

Q6. Describe the Changesets workflow.

Model answer: Developers run pnpm changeset to describe changes, creating a markdown file in the .changeset/ directory. After PR merge, CI runs changeset version to update package versions and changeset publish to publish to npm.

Q7. Why is CODEOWNERS important in a monorepo?

Model answer: In a monorepo, code from multiple teams exists in one repository, potentially making code ownership unclear. The CODEOWNERS file assigns responsible teams per directory, automatically assigning reviewers to PRs to ensure code quality and accountability.

Q8. How do you optimize Docker builds in a monorepo?

Model answer: Use Turborepo's turbo prune to extract a specific app and its dependencies, then build in Docker. Separate dependency installation and source building in a multi-stage build to leverage Docker layer caching.

Q9. What should you watch out for when migrating from polyrepo to monorepo?

Model answer: Preserving Git history is important. git-filter-repo can maintain history when moving to subdirectories. Do not migrate all repositories at once; move shared libraries first incrementally, validating CI/CD at each step.

Q10. What role do Nx module boundaries play in a monorepo?

Model answer: They restrict dependencies between packages via ESLint rules. Projects are assigned tags, and rules define which tagged projects can reference which. For example, preventing a frontend app from importing a backend-only package.


14. Quiz (5 Questions)

Q1. What does "dependsOn": ["^build"] mean in turbo.json?

Answer: The ^ prefix means run the build tasks of the current package's dependencies first. For example, if Package A depends on Package B, B's build completes before A's build starts. Without ^, ["build"] would mean a task dependency within the same package.

Q2. Why is shamefully-hoist=false important in pnpm monorepos?

Answer: pnpm installs packages in an isolated node_modules structure by default, preventing phantom dependencies (using undeclared dependencies). shamefully-hoist=false maintains this strict isolation. Enabling hoisting would allow access to undeclared packages, potentially causing issues during deployment.

Q3. What is the difference between linked and fixed in Changesets?

Answer: linked means when one package in the group gets a version bump, the rest bump to the same level (e.g., if one gets a minor bump, others do too). fixed means all packages in the group always share the exact same version number (e.g., all go from 3.0.0 to 3.1.0).

Q4. Why is git clone --filter=blob:none useful for monorepos?

Answer: Partial clone skips downloading blobs (file content) initially, fetching them on demand. This dramatically reduces initial clone time for large monorepos. Git only fetches blobs for checked-out files, so combined with sparse checkout, only necessary files are minimally downloaded.

Q5. How are cache keys determined in remote caching?

Answer: All task inputs including source files, configuration files, environment variables, and dependency build results are hashed to generate the cache key. Identical inputs produce identical hashes, allowing restoration of previously built results. Turborepo uses inputs and env settings in turbo.json to define which elements are included in the cache key.


15. References

  1. Nx Documentation — Official Nx docs
  2. Turborepo Documentation — Official Turborepo docs
  3. Changesets Documentation — Official Changesets docs
  4. pnpm Workspace — Official pnpm workspace docs
  5. Google Monorepo Paper — Why Google Stores Billions of Lines of Code in a Single Repository
  6. Lerna Documentation — Official Lerna docs
  7. Rush Documentation — Official Rush docs (Microsoft)
  8. Monorepo Explained — Monorepo tool comparison site
  9. Turborepo Caching — Caching mechanism details
  10. Nx Affected — How affected commands work
  11. Git Sparse Checkout — Large repository optimization
  12. CODEOWNERS Syntax — GitHub CODEOWNERS syntax