MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Monorepos with Lerna and Yarn Workspaces

Manage JavaScript monorepos: workspace configuration, shared packages, and CI pipelines.

MonorepoLernaYarnBuild Tools

By MinhVo

Introduction

Monorepos have become the standard architecture for managing multiple related packages in a single repository. Companies like Google, Meta, and Microsoft have used monorepo strategies for years, and the JavaScript ecosystem has followed suit with tools like Lerna, Yarn Workspaces, Nx, and Turborepo. Lerna, combined with Yarn Workspaces, provides a mature and battle-tested solution for JavaScript and TypeScript monorepos, enabling efficient package management, automated publishing, and streamlined development workflows.

Lerna handles package versioning, publishing, and running scripts across packages in topological order, while Yarn Workspaces manages dependency installation, hoisting, and automatic symlink creation. Together, they solve the fundamental challenges of monorepo development: avoiding dependency duplication across packages, enabling instant local package linking without publishing to npm, and automating the entire release process. This combination has been battle-tested by projects like Babel, Jest, React, and Create React App, making it one of the most mature monorepo solutions in the JavaScript ecosystem.

In this comprehensive guide, we explore the architecture, configuration, and real-world patterns for building production-grade monorepos with Lerna and Yarn Workspaces. We cover everything from initial setup and package structure to advanced versioning strategies, CI/CD integration, and performance optimization techniques that scale to hundreds of packages.

Monorepo Development Workflow

Understanding Monorepos: Why a Single Repository?

Before diving into tooling, it is important to understand why teams choose monorepos over the traditional polyrepo approach where each package lives in its own repository. A monorepo offers several compelling advantages that directly impact developer productivity and code quality.

Atomic cross-package changes are the most significant benefit. When a change spans multiple packagesβ€”for example, renaming a shared utility function that is used across five packagesβ€”a monorepo allows you to make all changes in a single commit and pull request. With a polyrepo, you would need to coordinate across five separate repositories, manage version compatibility windows, and risk introducing breaking changes during the transition period.

Single source of truth eliminates the "works on my machine" problem that arises when packages are scattered across repositories with different dependency versions, build configurations, and coding standards. In a monorepo, every package shares the same root configuration, the same dependency versions (via hoisting), and the same linting and formatting rules.

Simplified dependency management means you no longer need to publish intermediate packages to npm just to use them in a sibling project. Yarn Workspaces automatically creates symlinks between local packages, so changes to @myorg/core are immediately available to @myorg/ui without any publish step.

Shared tooling and configuration reduces duplication of build scripts, TypeScript configs, ESLint rules, and CI pipelines. A single tsconfig.base.json at the root can be extended by every package, ensuring consistent compiler settings across the entire codebase.

However, monorepos also introduce challenges: larger repository sizes, more complex build tooling, and the need for sophisticated dependency graphs. Lerna and Yarn Workspaces address these challenges directly.

Lerna and Yarn Workspaces: Core Concepts

What is Lerna?

Lerna is a JavaScript monorepo management tool originally created by the Babel team. It provides four core capabilities:

  • Package management: Install and link dependencies across all packages in a single command, respecting the dependency graph between internal packages
  • Script execution: Run npm scripts across packages in topological order, ensuring that dependencies are built before the packages that depend on them
  • Versioning: Manage package versions either in locked mode (all packages share one version) or independent mode (each package versioned separately)
  • Publishing: Automate npm publishing with conventional commits, generating changelogs and tagging releases automatically

Lerna 7+ was taken over by the Nx team at Nrwl, bringing significant performance improvements including Nx-powered caching, task scheduling, and affected detection. This revitalization means Lerna now combines its straightforward API with enterprise-grade performance.

What are Yarn Workspaces?

Yarn Workspaces is a feature of Yarn (both v1 Classic and v2+ Berry) that enables managing multiple packages in a single repository with native support for:

  • Hoisting: Common dependencies are hoisted to the root node_modules directory, eliminating duplicate installations and saving disk space
  • Symlink linking: Local packages are symlinked automatically in node_modules, so require("@myorg/core") resolves to the local packages/core directory
  • Single install: One yarn install at the root installs all dependencies for all packages, resolving the entire dependency graph in a single pass
  • Single lockfile: A single yarn.lock at the root ensures consistent dependency resolution across all packages and across all developer machines
  • Workspace protocol: The workspace:* protocol in package.json explicitly marks dependencies as local workspace packages, preventing accidental publishing of internal version ranges

How They Work Together

Lerna and Yarn Workspaces complement each other perfectly, with each tool handling the parts the other does not:

FeatureLernaYarn Workspaces
Dependency InstallationDelegates to YarnHandles hoisting and linking
Script ExecutionRuns scripts across packages in topological orderNot supported
VersioningManages versions (locked or independent)Not supported
PublishingAutomates npm publishing with conventional commitsNot supported
Package LinkingUses Yarn Workspaces under the hoodHandles symlinks and hoisting
CachingNx-powered caching in Lerna 7+Not supported

The key integration point is "useWorkspaces": true in lerna.json, which tells Lerna to delegate dependency management entirely to Yarn Workspaces. This prevents the two tools from conflicting over node_modules structure.

Monorepo Architecture

Architecture and Design Patterns

Monorepo Directory Structure

A well-organized monorepo separates published packages from private applications and shared tooling:

my-monorepo/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ core/                 # Core library (published to npm)
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   └── utils.ts
β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   └── utils.test.ts
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   β”œβ”€β”€ tsconfig.json
β”‚   β”‚   └── jest.config.js
β”‚   β”œβ”€β”€ ui/                   # UI component library (published)
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   └── utils/                # Shared utilities (published)
β”‚       β”œβ”€β”€ src/
β”‚       β”œβ”€β”€ __tests__/
β”‚       β”œβ”€β”€ package.json
β”‚       └── tsconfig.json
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ web/                  # Web application (private)
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   β”œβ”€β”€ api/                  # API server (private)
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   └── docs/                 # Documentation site (private)
β”‚       β”œβ”€β”€ src/
β”‚       β”œβ”€β”€ package.json
β”‚       └── tsconfig.json
β”œβ”€β”€ tools/
β”‚   β”œβ”€β”€ eslint-config/        # Shared ESLint configuration
β”‚   β”‚   β”œβ”€β”€ index.js
β”‚   β”‚   └── package.json
β”‚   β”œβ”€β”€ tsconfig/             # Shared TypeScript configurations
β”‚   β”‚   β”œβ”€β”€ base.json
β”‚   β”‚   β”œβ”€β”€ react.json
β”‚   β”‚   β”œβ”€β”€ node.json
β”‚   β”‚   └── package.json
β”‚   └── jest-preset/          # Shared Jest configuration
β”‚       β”œβ”€β”€ jest-preset.js
β”‚       └── package.json
β”œβ”€β”€ lerna.json
β”œβ”€β”€ package.json
β”œβ”€β”€ yarn.lock
β”œβ”€β”€ tsconfig.json             # Root TypeScript project references
└── .github/
    └── workflows/
        β”œβ”€β”€ ci.yml
        └── publish.yml

This structure provides several benefits: the packages/ directory contains publishable libraries with clear boundaries, apps/ contains private applications that consume those libraries, and tools/ houses shared configuration packages that enforce consistency across the entire monorepo.

Lerna Configuration Deep Dive

// lerna.json
{
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true,
  "packages": [
    "packages/*",
    "apps/*",
    "tools/*"
  ],
  "command": {
    "publish": {
      "conventionalCommits": true,
      "message": "chore(release): publish",
      "access": "public",
      "allowBranch": ["main"]
    },
    "version": {
      "allowBranch": ["main", "release/*"],
      "conventionalCommits": true,
      "createRelease": "github",
      "push": true
    }
  }
}

The "version" field is critical. In "independent" mode, each package maintains its own version number, allowing packages to evolve at different rates. In "fixed" mode (set to a version string like "3.0.0"), all packages share the same version and are always released together. Independent mode is generally preferred for loosely coupled packages, while fixed mode works well for tightly coupled design systems where components must stay in sync.

The "conventionalCommits": true setting in both publish and version commands enables automatic version bumping based on commit message prefixes: fix: triggers a patch bump, feat: triggers a minor bump, and BREAKING CHANGE: in the commit body triggers a major bump.

Root package.json Configuration

// package.json
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*",
    "tools/*"
  ],
  "scripts": {
    "build": "lerna run build",
    "build:changed": "lerna run build --since=origin/main",
    "test": "lerna run test",
    "test:changed": "lerna run test --since=origin/main",
    "lint": "lerna run lint",
    "clean": "lerna clean --yes && yarn install",
    "publish": "lerna publish",
    "version": "lerna version",
    "typecheck": "tsc --build"
  },
  "devDependencies": {
    "lerna": "^7.0.0",
    "typescript": "^5.3.0",
    "@myorg/tsconfig": "*",
    "@myorg/eslint-config": "*"
  }
}

The "private": true field prevents the root from being accidentally published to npm. The --since=origin/main flag is a performance optimization that only runs commands on packages that have changed since the last merge to main, dramatically reducing CI build times.

Package Configuration with Workspace Protocol

// packages/core/package.json
{
  "name": "@myorg/core",
  "version": "1.0.0",
  "description": "Core library for the monorepo",
  "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"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.js",
      "types": "./dist/utils.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup src/index.ts src/utils.ts --format cjs,esm --dts",
    "test": "jest",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "@myorg/utils": "workspace:*"
  },
  "devDependencies": {
    "@myorg/tsconfig": "workspace:*",
    "@myorg/eslint-config": "workspace:*",
    "typescript": "^5.3.0",
    "jest": "^29.0.0",
    "tsup": "^8.0.0"
  },
  "publishConfig": {
    "access": "public"
  }
}

The workspace:* protocol is essential. It tells Yarn that @myorg/utils and @myorg/tsconfig are local workspace packages, not npm packages. During yarn install, Yarn resolves these to symlinks. When publishing, Lerna automatically replaces workspace:* with the actual version range (e.g., "^1.0.0"), so published packages work correctly for npm consumers.

Package Dependencies

Step-by-Step Implementation

Setting Up the Monorepo from Scratch

# Create the project directory
mkdir my-monorepo && cd my-monorepo
git init
 
# Initialize with Yarn and enable workspaces
cat > package.json << 'EOF'
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*", "tools/*"],
  "devDependencies": {
    "lerna": "^7.0.0"
  },
  "scripts": {
    "build": "lerna run build",
    "test": "lerna run test",
    "lint": "lerna run lint"
  }
}
EOF
 
# Initialize Lerna with independent versioning
npx lerna init --independent
 
# Install all dependencies
yarn install

Creating Internal Packages

# Create the shared TypeScript config package
mkdir -p tools/tsconfig
cat > tools/tsconfig/package.json << 'EOF'
{
  "name": "@myorg/tsconfig",
  "version": "1.0.0",
  "private": true,
  "main": "base.json"
}
EOF
 
cat > tools/tsconfig/base.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020", "DOM"],
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleResolution": "node",
    "outDir": "dist",
    "rootDir": "src"
  },
  "exclude": ["node_modules", "dist"]
}
EOF
 
# Create the core library package
mkdir -p packages/core/src packages/core/__tests__
cat > packages/core/package.json << 'EOF'
{
  "name": "@myorg/core",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "lint": "eslint src/"
  },
  "dependencies": {
    "@myorg/utils": "workspace:*"
  },
  "devDependencies": {
    "@myorg/tsconfig": "workspace:*",
    "typescript": "^5.3.0",
    "jest": "^29.0.0"
  }
}
EOF
 
cat > packages/core/tsconfig.json << 'EOF'
{
  "extends": "@myorg/tsconfig/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}
EOF
 
cat > packages/core/src/index.ts << 'EOF'
export { createApp } from './app';
export type { AppConfig, AppState } from './types';
export { VERSION } from './constants';
EOF
 
# Create the utils package
mkdir -p packages/utils/src
cat > packages/utils/package.json << 'EOF'
{
  "name": "@myorg/utils",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "jest"
  },
  "devDependencies": {
    "@myorg/tsconfig": "workspace:*",
    "typescript": "^5.3.0"
  }
}
EOF
 
# Reinstall to link new packages
yarn install

Running Commands Across Packages

Lerna runs scripts in topological order, respecting the dependency graph:

# Build all packages (respects dependency order)
yarn lerna run build
 
# Build only packages changed since main branch
yarn lerna run build --since=origin/main
 
# Build a specific package and all its dependencies
yarn lerna run build --scope=@myorg/core --include-dependencies
 
# Run tests in parallel (safe for independent test suites)
yarn lerna run test --parallel
 
# Run tests for a specific package
yarn lerna run test --scope=@myorg/core
 
# Stream output in real-time (useful for debugging)
yarn lerna run build --stream
 
# Run with verbose logging
yarn lerna run build --verbose
 
# Run with a concurrency limit
yarn lerna run build --concurrency=4

The --since=origin/main flag is critical for CI performance. Instead of building all 50 packages, it only builds the 3 that changed, along with their dependents. This can reduce CI times from 20 minutes to under 2 minutes.

Versioning and Publishing Strategies

Independent vs. Locked Versioning

Independent versioning allows each package to have its own version number. This is ideal when packages are loosely coupled and evolve at different rates:

# Bump versions based on conventional commits
yarn lerna version --conventional-commits
 
# This analyzes commit messages since last release:
# fix(utils): handle edge case in parseDate  β†’ patch bump @myorg/utils
# feat(core): add plugin system              β†’ minor bump @myorg/core
# feat(ui): new Button component             β†’ minor bump @myorg/ui
# BREAKING CHANGE: remove deprecated API     β†’ major bump @myorg/core

Locked versioning keeps all packages at the same version. This is common for design systems where all components must be released together:

// lerna.json - locked mode
{
  "version": "2.5.0"
}

Publishing with Conventional Commits

# Full publish workflow
yarn lerna publish --conventional-commits
 
# What happens:
# 1. Lerna analyzes commits since last release
# 2. Determines version bumps for each changed package
# 3. Updates package.json versions
# 4. Generates/updates CHANGELOG.md files
# 5. Creates a git commit with version changes
# 6. Creates git tags (e.g., @myorg/core@1.2.0)
# 7. Pushes commits and tags to remote
# 8. Publishes changed packages to npm
 
# Canary release (pre-release version)
yarn lerna publish --canary --preid=alpha
# Results in versions like: 1.2.1-alpha.0+abc1234
 
# Dry run (see what would happen without doing it)
yarn lerna publish --dry-run --conventional-commits
 
# Publish from a specific tag
yarn lerna publish from-package

GitHub Actions for Automated Publishing

# .github/workflows/publish.yml
name: Publish Packages
on:
  push:
    branches: [main]
 
concurrency:
  group: publish-${{ github.ref }}
  cancel-in-progress: false
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
 
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'
 
      - run: yarn install --frozen-lockfile
 
      - name: Build changed packages
        run: yarn lerna run build --since=origin/main
 
      - name: Test changed packages
        run: yarn lerna run test --since=origin/main
 
      - name: Lint changed packages
        run: yarn lerna run lint --since=origin/main
 
  publish:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
 
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
          cache: 'yarn'
 
      - run: yarn install --frozen-lockfile
      - run: yarn lerna run build
 
      - name: Configure Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
 
      - name: Version and Publish
        run: yarn lerna publish --yes --conventional-commits --create-release github
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

CI/CD Pipeline

Dependency Management Deep Dive

How Hoisting Works

When you run yarn install in a workspace-enabled monorepo, Yarn performs hoisting: it moves shared dependencies to the root node_modules directory instead of installing them in each package's node_modules:

node_modules/                    # Root (hoisted)
β”œβ”€β”€ react/                       # Shared by all packages
β”œβ”€β”€ typescript/                  # Shared devDependency
β”œβ”€β”€ @myorg/core -> packages/core  # Symlink
β”œβ”€β”€ @myorg/utils -> packages/utils # Symlink
β”œβ”€β”€ @myorg/ui -> packages/ui      # Symlink
└── ...
packages/core/node_modules/      # Only non-hoistable deps
└── (some-pkg that conflicts)    #   live here

This saves significant disk space and install time. If 10 packages all depend on react@^18.0.0, React is installed once at the root rather than 10 times.

Controlling Hoisting with nohoist

Sometimes hoisting causes problems, particularly with peer dependencies or packages that rely on specific node_modules layouts:

// package.json
{
  "workspaces": {
    "packages": ["packages/*", "apps/*"],
    "nohoist": [
      "**/react-native",
      "**/react-native/**",
      "@myorg/mobile/**"
    ]
  }
}

The nohoist pattern prevents specified packages from being hoisted, keeping them in their local node_modules. This is particularly important for React Native projects where Metro bundler expects a specific module resolution structure.

Cross-Package Dependency Graphs

Understanding and managing the dependency graph between packages is crucial:

# View the dependency graph
yarn lerna list --graph
 
# Output: JSON showing which packages depend on which
# {
#   "@myorg/core": ["@myorg/utils"],
#   "@myorg/ui": ["@myorg/core", "@myorg/utils"],
#   "@myorg/web": ["@myorg/core", "@myorg/ui", "@myorg/utils"]
# }
 
# List all packages and their versions
yarn lerna list --long
 
# List packages that have changed since main
yarn lerna changed

A healthy dependency graph is a directed acyclic graph (DAG) with no circular dependencies. Circular dependencies cause build failures, infinite loops during version bumping, and confusing runtime behavior.

Real-World Use Cases

Use Case 1: Design System Monorepo

A design system with independently versioned components:

design-system/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ tokens/          # @ds/tokens - Design tokens (colors, spacing)
β”‚   β”œβ”€β”€ icons/           # @ds/icons - Icon library
β”‚   β”œβ”€β”€ button/          # @ds/button - Button component
β”‚   β”œβ”€β”€ card/            # @ds/card - Card component
β”‚   β”œβ”€β”€ form/            # @ds/form - Form components
β”‚   └── theme/           # @ds/theme - Theme provider
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ playground/      # Component playground (private)
β”‚   └── docs/            # Documentation site (private)
└── tools/
    └── eslint-config/   # Shared ESLint config

Each component is published independently with its own version. The tokens package changes rarely (major version bumps for brand redesigns), while button might get minor updates monthly. Conventional commits automate this entire flow.

Use Case 2: Full-Stack TypeScript Application

A full-stack app sharing types between frontend and backend:

fullstack-app/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ types/           # @app/types - Shared TypeScript types
β”‚   β”œβ”€β”€ db/              # @app/db - Database client and migrations
β”‚   β”œβ”€β”€ auth/            # @app/auth - Authentication logic
β”‚   └── api-client/      # @app/api-client - Typed API client
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ web/             # Next.js frontend
β”‚   β”œβ”€β”€ api/             # Express/Fastify backend
β”‚   └── mobile/          # React Native app

The types package defines request/response interfaces that both the API server and web client import, ensuring end-to-end type safety. When you add a new API endpoint, you define its types in @app/types, implement it in @app/api, and consume it in @app/webβ€”all in a single pull request.

Use Case 3: Multi-Framework Component Library

A component library supporting React, Vue, and Svelte:

multi-framework/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ core/            # @ml/core - Framework-agnostic logic
β”‚   β”œβ”€β”€ react/           # @ml/react - React bindings
β”‚   β”œβ”€β”€ vue/             # @ml/vue - Vue bindings
β”‚   └── svelte/          # @ml/svelte - Svelte bindings

The core package contains pure TypeScript logic (event handling, state management, accessibility). Each framework package wraps this core in framework-specific components. Locked versioning ensures all framework packages stay in sync.

Performance Optimization

Caching with Nx (Lerna 7+)

Lerna 7+ integrates Nx for intelligent caching:

// nx.json
{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "test", "lint", "typecheck"],
        "parallel": 3
      }
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": [],
    "production": ["default", "!{projectRoot}/**/__tests__/**/*"]
  },
  "targetDefaults": {
    "build": {
      "inputs": ["production", "^production"],
      "dependsOn": ["^build"]
    },
    "test": {
      "inputs": ["default"]
    }
  }
}

With caching enabled, Lerna skips builds for packages whose inputs have not changed. If you modify only the web app, running lerna run build will use cached output for core, utils, and uiβ€”reducing build time from minutes to seconds.

CI/CD Optimization

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for --since comparisons
 
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'yarn'
 
      - run: yarn install --frozen-lockfile
 
      # Restore Nx cache for faster builds
      - uses: actions/cache@v4
        with:
          path: node_modules/.cache/nx
          key: nx-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
          restore-keys: |
            nx-${{ runner.os }}-${{ hashFiles('yarn.lock') }}-
            nx-${{ runner.os }}-
 
      - name: Build changed
        run: yarn lerna run build --since=origin/main
 
      - name: Test changed
        run: yarn lerna run test --since=origin/main
 
      - name: Lint changed
        run: yarn lerna run lint --since=origin/main
 
      - name: Typecheck
        run: yarn tsc --build

Comparison with Modern Alternatives

FeatureLerna + YarnTurborepoNxpnpm Workspaces
VersioningBuilt-inManualManualManual
PublishingBuilt-inManualManualManual
CachingNx-powered (v7+)Built-inBuilt-inManual
Affected DetectionVia --sinceBuilt-inAdvancedManual
Task PipelineTopologicalturbo.jsonnx.jsonManual
Remote CachingVia Nx CloudVercelNx CloudManual
Learning CurveLowLowMediumLow
Best ForPublishing packagesSimple monoreposEnterprise monoreposSimple monorepos

Turborepo is excellent when you primarily need fast builds and caching without publishing. Nx provides the most sophisticated tooling for large enterprise monorepos with hundreds of packages. Lerna + Yarn remains the best choice when your primary workflow involves publishing packages to npm, as versioning and publishing are first-class features.

Common Pitfalls and Solutions

Dependency hoisting issues arise when two packages need different versions of the same dependency. Solution: use the resolutions field in root package.json to force a specific version, or use nohoist for packages that need isolation.

Circular dependencies cause build failures and infinite loops. Solution: use madge or eslint-plugin-import to detect cycles, and restructure packages by extracting shared logic into a new package.

Stale build artifacts cause confusing errors when packages reference outdated compiled output. Solution: add a "clean" script to each package and run lerna run clean before building. Consider adding "prebuild": "rm -rf dist" to each package.

Version conflicts with peer dependencies occur when hoisted versions do not satisfy all packages' peer requirements. Solution: declare peer dependencies in the root package.json and use --frozen-lockfile in CI to catch mismatches early.

Slow CI builds happen when every PR builds all packages. Solution: always use --since=origin/main for CI commands, enable Nx caching, and configure GitHub Actions caching for node_modules/.cache.

Future Outlook

The monorepo tooling landscape continues to evolve rapidly. Lerna's acquisition by the Nx team brought enterprise-grade caching and task scheduling to the most popular monorepo tool. Turborepo, backed by Vercel, offers a simpler alternative with excellent Next.js integration. pnpm workspaces provide the most efficient dependency management with content-addressable storage.

The emergence of Bun and Deno as alternative JavaScript runtimes introduces new workspace management paradigms. Bun's built-in workspace support and Deno's import maps offer different approaches to the same monorepo challenges. However, the core concepts of workspace management, topological task execution, and automated publishing that Lerna and Yarn Workspaces pioneered will remain relevant regardless of runtime.

Conclusion

Lerna combined with Yarn Workspaces provides a mature, production-proven solution for JavaScript monorepos. The combination excels at the complete monorepo lifecycle: from development with automatic symlink linking and hoisted dependencies, through CI/CD with topological script execution and change detection, to publishing with automated version bumping and changelog generation.

Key takeaways:

  1. Lerna + Yarn is ideal for projects needing automated versioning and publishing with a simple configuration surface
  2. Independent versioning provides flexibility for loosely coupled packages; locked versioning suits tightly coupled design systems
  3. Conventional commits automate the entire release pipeline from version bumps to changelog generation to npm publishing
  4. The workspace:* protocol ensures correct local linking during development and correct version ranges after publishing
  5. --since=origin/main is the single most impactful CI optimization, reducing build times by building only changed packages
  6. Nx integration in Lerna 7+ brings enterprise-grade caching that makes even large monorepos fast

Start with Lerna and Yarn Workspaces for new monorepos that need publishing workflows. Consider adding Turborepo or Nx as the project grows and build performance becomes a bottleneck. The investment in monorepo tooling pays dividends in developer productivity, code quality, and release automation.