Introduction to Lerna
Lerna is a powerful build and orchestration tool designed specifically for JavaScript and TypeScript monorepos. It streamlines the management of multiple packages within a single repository, handling everything from dependency management to versioning and publishing. Whether you're maintaining a large open-source project with dozens of packages or a private enterprise codebase, Lerna provides the structure and automation needed to keep your development workflow efficient and predictable.
What is Lerna and Why It Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →At its core, Lerna is a tool that splits your monolithic codebase into manageable, independently versioned packages while keeping them in a single Git repository. It addresses several critical challenges in monorepo development:
- Dependency Hoisting: Lerna intelligently hoists shared dependencies to the root
node_modules, reducing duplication and saving disk space - Cross-Package Linking: Automatically symlinks packages that depend on each other, enabling local development without publishing
- Parallel Task Execution: Runs scripts across multiple packages simultaneously, dramatically improving CI times
- Versioning Automation: Determines which packages changed since the last release and bumps versions accordingly
- Consistent Publishing: Publishes packages to npm registries in the correct dependency order
Lerna matters because manual monorepo management at scale becomes unsustainable. Without it, teams face circular dependency nightmares, inconsistent versions, duplicated devDependencies, and painfully slow installs. Lerna, often paired with npm workspaces or Yarn workspaces, provides a battle-tested solution that scales from small projects to massive repositories like Babel, Jest, and Create React App.
Getting Started with Lerna
Before diving into configuration details, let's set up a basic Lerna project. You can initialize Lerna in an existing repository or create a fresh one.
# Initialize a new Lerna monorepo
npx lerna init
# Or with specific options
npx lerna init --packages="packages/*" --independent
This command creates the following structure:
my-monorepo/
├── packages/ # Directory for your packages
├── lerna.json # Main Lerna configuration file
├── package.json # Root package.json
└── .gitignore
The lerna.json file is the heart of your configuration. Let's explore every aspect of it in detail.
Core Configuration: lerna.json
The lerna.json file defines how Lerna behaves across all packages. Here is a comprehensive breakdown of every configuration option available.
version
This is the most fundamental setting. It determines whether all packages share a single version (fixed/locked mode) or maintain independent versions.
{
"version": "1.0.0"
}
With a fixed version like "1.0.0", all packages are published with the exact same version number. When you run lerna version, Lerna bumps this single version and applies it to every package. This is ideal for tightly coupled packages like a UI component library where components are always released together.
{
"version": "independent"
}
With "independent", each package manages its own version in its local package.json. When you run lerna version, Lerna prompts you to choose version bumps for each changed package individually. This is perfect for loosely coupled packages like a collection of utilities, plugins, or microservices that evolve at different paces.
packages
Defines glob patterns for where Lerna should look for packages. The default is ["packages/*"], but complex monorepos often need multiple patterns.
{
"packages": [
"packages/*",
"tools/*",
"plugins/*",
"!private-packages/*"
]
}
You can use negation patterns with ! to exclude directories. Lerna uses globby under the hood, so all standard glob patterns work.
npmClient
Specifies which package manager Lerna should use for installing dependencies and running scripts. The default is "npm".
{
"npmClient": "yarn"
}
Supported values include "npm", "yarn", "pnpm", and as of newer versions, you can even specify exact versions:
{
"npmClient": "yarn",
"npmClientArgs": ["--production", "--frozen-lockfile"]
}
The npmClientArgs array passes additional arguments to the package manager during installs.
useWorkspaces
When set to true, Lerna delegates package management to the underlying workspace implementation of your chosen package manager (npm workspaces, Yarn workspaces, or pnpm workspaces). This is now the recommended approach.
{
"useWorkspaces": true
}
When enabled, you must also define the workspaces field in your root package.json:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*",
"tools/*"
]
}
With useWorkspaces: true, Lerna no longer handles bootstrapping or symlinking itself—it relies entirely on the package manager's workspace functionality for that. This dramatically improves install performance and eliminates the deprecated lerna bootstrap command.
command
The command object allows you to configure behavior for specific Lerna commands. This is where much of the fine-grained control lives.
{
"command": {
"publish": {
"ignoreChanges": ["*.md", "**/*.test.js"],
"message": "chore(release): publish %s",
"registry": "https://custom-registry.example.com"
},
"version": {
"allowBranch": ["main", "release/*"],
"conventionalCommits": true,
"exact": true
},
"bootstrap": {
"npmClientArgs": ["--no-optional"]
}
}
}
Each sub-object maps to a Lerna command name. Let's explore the most important command configurations.
command.publish Configuration
The publish command has the richest set of options, controlling how packages are released to registries.
{
"command": {
"publish": {
"ignoreChanges": [
"**/__tests__/**",
"**/*.md",
"**/*.spec.ts"
],
"message": "chore(release): publish %v",
"registry": "https://registry.npmjs.org",
"verifyAccess": true,
"verifyConditions": true,
"allowBranch": ["main", "master", "release/*"],
"includePrivate": false,
"includeMergedTags": true,
"tagVersionPrefix": "v",
"otp": "123456"
}
}
}
- ignoreChanges: Array of glob patterns. Files matching these patterns won't trigger a package version bump. Useful for ignoring documentation updates or test changes
- message: Commit message template when Lerna commits version changes.
%sis replaced with the new version,%vwith the version prefixed - registry: Custom npm registry URL for publishing. Essential for private registries like GitHub Packages or Verdaccio
- verifyAccess: Checks npm access levels before publishing. Disable if you're confident about permissions
- verifyConditions: Runs pre-publish validations. Disable to skip checks but this is risky
- allowBranch: Restricts publishing to specific Git branches. Prevents accidental releases from feature branches
- includePrivate: When
true, packages with"private": truein theirpackage.jsonwill also be published - includeMergedTags: Includes tags from merged branches when determining changes
- tagVersionPrefix: Prefix for Git tags. Default is
"v", producing tags likev1.0.0 - otp: One-time password for npm accounts with 2FA enabled
command.version Configuration
The version command determines how Lerna calculates and applies version bumps.
{
"command": {
"version": {
"allowBranch": ["main", "release/*"],
"conventionalCommits": true,
"exact": true,
"includeMergedTags": true,
"message": "chore(release): bump versions to %v",
"noCommitHooks": false,
"push": true,
"sign": false,
"yes": false,
"preid": "beta",
"changelog": {
"types": {
"feat": { "section": "Features" },
"fix": { "section": "Bug Fixes" },
"perf": { "section": "Performance Improvements" },
"docs": { "section": "Documentation" }
}
}
}
}
}
- allowBranch: Same as publish—restricts versioning to specific branches
- conventionalCommits: When
true, Lerna uses conventional commit messages to determine semantic version bumps. A commit starting withfeat!orBREAKING CHANGEtriggers a major bump,feattriggers minor,fixtriggers patch - exact: Forces exact version numbers in dependencies (no caret or tilde ranges)
- includeMergedTags: Considers tags from merged branches
- message: Commit message template.
%vis the version with prefix - noCommitHooks: Skips Git commit hooks when Lerna commits version changes
- push: Automatically pushes commits and tags to the remote after versioning
- sign: Signs Git tags with GPG
- yes: Skips all confirmation prompts. Dangerous but useful in CI environments
- preid: Prefix for prerelease versions. With
"preid": "beta", versions look like1.0.0-beta.0 - changelog: Configures changelog generation when using
--changelogflag. Maps commit types to changelog sections
command.bootstrap Configuration (Legacy)
Note: lerna bootstrap is deprecated when using workspaces. If you're not using workspaces, these options still apply.
{
"command": {
"bootstrap": {
"npmClientArgs": ["--no-optional", "--production"],
"ignore": ["package-*"],
"scope": ["@myorg/utils"],
"forceLocal": false,
"hoist": true
}
}
}
- npmClientArgs: Extra arguments passed to npm/yarn during bootstrap
- ignore: Glob patterns for packages to skip during bootstrap
- scope: Restricts bootstrap to specific packages
- forceLocal: Forces local linking even when a matching version exists in the registry
- hoist: Hoists dependencies to the root
node_modules. Redundant with workspaces enabled
command.run and command.exec Configuration
These commands execute scripts across packages. lerna run executes a script defined in each package's scripts section, while lerna exec runs arbitrary shell commands.
{
"command": {
"run": {
"npmClient": "yarn",
"stream": true,
"parallel": true,
"concurrency": 4,
"ignore": ["package-*"],
"scope": ["@myorg/*"],
"noBail": false,
"bail": true,
"prefix": true
},
"exec": {
"stream": true,
"parallel": false,
"concurrency": 2,
"bail": true
}
}
}
- stream: Outputs child process streams immediately instead of buffering. Essential for real-time logging in CI
- parallel: Runs scripts in parallel across packages. With
parallel: true, order is not guaranteed - concurrency: Limits the number of parallel processes. Prevents resource exhaustion
- ignore: Glob patterns to exclude certain packages
- scope: Glob patterns to include only specific packages
- noBail: Continue executing even if one package fails. Useful for collecting all errors
- bail: Stop immediately on first error. Default behavior, safer for CI
- prefix: Prefixes output with the package name for easy identification
command.add Configuration
The lerna add command adds dependencies to matched packages.
{
"command": {
"add": {
"dev": false,
"exact": false,
"peer": false,
"registry": "https://custom-registry.example.com"
}
}
}
- dev: When
true, adds as a devDependency instead of a regular dependency - exact: Pins exact version without caret/tilde range
- peer: Adds as a peerDependency
- registry: Custom registry for fetching the package
command.import Configuration
The lerna import command imports external packages into the monorepo, preserving Git history.
{
"command": {
"import": {
"flatten": true,
"dest": "packages",
"preserveCommit": true
}
}
}
- flatten: Flattens the imported package's commit history into a single commit
- dest: Target directory for the imported package
- preserveCommit: Preserves original commit authors and timestamps
Package Management Modes
Lerna supports two distinct modes for managing your monorepo's dependencies: the legacy bootstrap mode and the modern workspaces mode. Understanding the difference is crucial for configuration.
Legacy Bootstrap Mode (Deprecated)
In the original Lerna model, lerna bootstrap handled all dependency installation and cross-package linking. This mode is characterized by:
{
"useWorkspaces": false,
"npmClient": "npm"
}
With this configuration, you run lerna bootstrap after npm install at the root. Lerna manually creates symlinks between packages and installs their dependencies. This mode is slower and duplicates dependencies across packages.
Workspaces Mode (Recommended)
Modern Lerna leverages native package manager workspaces:
// lerna.json
{
"useWorkspaces": true,
"npmClient": "yarn"
}
// root package.json
{
"private": true,
"workspaces": ["packages/*"]
}
With workspaces, a single yarn install (or npm install / pnpm install) at the root handles everything: hoisting, symlinking, and installing all packages. Lerna no longer needs to bootstrap. The lerna bootstrap command is effectively replaced by your package manager's install command.
Working with package.json at the Root
The root package.json works in tandem with lerna.json. Several fields here directly affect Lerna's behavior.
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*",
"tools/*",
"!private-packages/*"
],
"scripts": {
"build": "lerna run build --stream",
"test": "lerna run test --parallel",
"lint": "lerna run lint --concurrency=4",
"release": "lerna version --conventional-commits && lerna publish",
"clean": "lerna clean --yes && rimraf node_modules"
},
"devDependencies": {
"lerna": "^8.0.0",
"typescript": "^5.3.0",
"jest": "^29.0.0"
}
}
Key points about the root package.json:
- private: Must be
trueto prevent accidental publishing of the root - workspaces: Mirrors the
packagesarray inlerna.json. WhenuseWorkspaces: true, this field is the source of truth for package locations - scripts: Define convenience scripts that invoke Lerna commands. This is the standard pattern for CI/CD pipelines
- devDependencies: Shared development tools like TypeScript, ESLint, Jest, and Lerna itself belong here. Workspace hoisting makes them available to all packages
Advanced Configuration Techniques
Using Environment Variables in lerna.json
Lerna supports environment variable interpolation in lerna.json. This is invaluable for CI/CD pipelines where values change per environment.
{
"command": {
"publish": {
"registry": "${NPM_REGISTRY_URL}",
"otp": "${NPM_OTP}"
}
}
}
You can reference any environment variable using ${VARIABLE_NAME} syntax. Lerna resolves these at runtime.
Programmatic Configuration with lerna.json Extensions
For complex scenarios, you can use a JavaScript configuration file. Create a lerna.json that points to a JS module:
// lerna.js (CommonJS)
const fs = require('fs');
const path = require('path');
module.exports = {
version: 'independent',
packages: ['packages/*'],
command: {
publish: {
ignoreChanges: getIgnoredPatterns(),
message: getCommitMessage()
}
}
};
function getIgnoredPatterns() {
// Dynamically read ignore patterns from a file
const ignoreFile = path.join(__dirname, '.lernaignore');
if (fs.existsSync(ignoreFile)) {
return fs.readFileSync(ignoreFile, 'utf8').split('\n').filter(Boolean);
}
return ['**/*.md'];
}
function getCommitMessage() {
const date = new Date().toISOString().split('T')[0];
return `chore(release): publish on ${date}`;
}
Then reference it:
// lerna.json
{
"extends": "./lerna.js"
}
This approach allows you to compute configuration values at runtime, read from external sources, or implement complex conditional logic.
Selective Package Execution with Scope and Ignore
Fine-grained control over which packages Lerna operates on is achieved through scope and ignore patterns, both at the configuration level and command line.
{
"command": {
"run": {
"scope": ["@myorg/core", "@myorg/utils"],
"ignore": ["@myorg/deprecated-*", "*-internal"]
}
}
}
Command-line overrides take precedence:
# Override scope for a single run
lerna run test --scope="@myorg/core" --scope="@myorg/utils"
# Exclude specific packages
lerna run build --ignore="@myorg/legacy-*"
# Combine scope and ignore
lerna run lint --scope="@myorg/*" --ignore="@myorg/tools-*"
The --since flag is particularly powerful—it limits operations to packages that changed since a specific Git reference:
# Only test packages changed since main branch
lerna run test --since=main
# Build packages changed since last tag
lerna run build --since=last-tag
# Lint packages changed in the last commit
lerna run lint --since=HEAD~1
Conventional Commits Integration
Enabling conventional commits transforms your versioning workflow from manual to automatic. Here's a complete configuration:
{
"version": "independent",
"command": {
"version": {
"conventionalCommits": true,
"changelog": {
"types": [
{ "type": "feat", "section": "✨ Features" },
{ "type": "fix", "section": "🐛 Bug Fixes" },
{ "type": "perf", "section": "⚡ Performance" },
{ "type": "docs", "section": "📖 Documentation" },
{ "type": "style", "section": "💅 Code Style", "hidden": true },
{ "type": "refactor", "section": "♻️ Refactoring" },
{ "type": "test", "section": "🧪 Testing", "hidden": true },
{ "type": "chore", "section": "🔧 Maintenance", "hidden": true },
{ "type": "ci", "section": "🚀 CI/CD", "hidden": true }
]
},
"includeMergedTags": true,
"allowBranch": ["main"]
}
}
}
With this setup, commit messages like feat: add user authentication trigger a minor version bump, fix: resolve login timeout triggers a patch bump, and feat!: redesign API (with the ! indicating breaking change) triggers a major bump. Lerna reads commit history since the last release tag to determine the appropriate version increment.
Multi-Registry Publishing
For organizations using multiple registries, Lerna supports per-package registry configuration through publishConfig in individual package.json files:
// packages/internal-utils/package.json
{
"name": "@myorg/internal-utils",
"version": "1.0.0",
"publishConfig": {
"registry": "https://private-registry.myorg.com"
}
}
// packages/public-utils/package.json
{
"name": "@myorg/public-utils",
"version": "1.0.0",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
}
}
Lerna respects these per-package configurations during publish, allowing seamless hybrid public/private workflows.
Complete lerna.json Reference
Here is a comprehensive lerna.json file showcasing all major configuration options in one place:
{
"version": "independent",
"packages": [
"packages/*",
"shared/*",
"!private/*"
],
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"publish": {
"ignoreChanges": [
"**/__fixtures__/**",
"**/__tests__/**",
"**/*.md",
"**/*.test.{js,ts,tsx}",
"**/*.spec.{js,ts,tsx}"
],
"message": "chore(release): publish %v",
"registry": "${NPM_REGISTRY:-https://registry.npmjs.org}",
"verifyAccess": true,
"verifyConditions": true,
"allowBranch": ["main", "master", "release/*"],
"includePrivate": false,
"includeMergedTags": true,
"tagVersionPrefix": "v"
},
"version": {
"allowBranch": ["main", "master"],
"conventionalCommits": true,
"exact": true,
"includeMergedTags": true,
"message": "chore(release): bump versions to %v",
"noCommitHooks": false,
"push": true,
"sign": false,
"yes": false,
"preid": "beta",
"changelog": {
"types": {
"feat": { "section": "Features" },
"fix": { "section": "Bug Fixes" },
"docs": { "section": "Documentation" },
"perf": { "section": "Performance" }
}
}
},
"run": {
"npmClient": "yarn",
"stream": true,
"parallel": false,
"concurrency": 4,
"bail": true,
"prefix": true,
"ignore": ["@myorg/dev-tools"]
},
"exec": {
"stream": true,
"bail": true,
"concurrency": 4
},
"add": {
"dev": false,
"exact": true,
"peer": false
},
"clean": {
"yes": true
}
},
"loglevel": "info",
"progress": true
}
Best Practices for Lerna Configuration
1. Always Use Workspaces
Enable useWorkspaces: true and define workspaces in root package.json. This is the modern, faster, and officially recommended approach. It eliminates the need for lerna bootstrap and reduces dependency duplication.
{
"useWorkspaces": true,
"npmClient": "yarn"
}
2. Prefer Independent Versioning for Heterogeneous Packages
If your packages serve different purposes and have different release cycles, use "version": "independent". This prevents unnecessary version bumps for unchanged packages and gives you granular control.
{
"version": "independent"
}
3. Lock Down Publishing with allowBranch
Always configure allowBranch to prevent accidental releases from feature branches:
{
"command": {
"publish": {
"allowBranch": ["main", "release/*"]
},
"version": {
"allowBranch": ["main", "release/*"]
}
}
}
4. Leverage ignoreChanges Aggressively
Prevent documentation, test, and configuration changes from triggering unnecessary version bumps:
{
"command": {
"publish": {
"ignoreChanges": [
"**/*.md",
"**/*.test.{js,ts}",
"**/jest.config.js",
"**/tsconfig.json",
"**/.eslintrc.js"
]
}
}
}
5. Use Conventional Commits for Predictable Versioning
Enable conventionalCommits: true and enforce commit message conventions in your team. This removes human judgment from version bump decisions:
{
"command": {
"version": {
"conventionalCommits": true,
"exact": true
}
}
}
6. Set concurrency Limits in CI
Unlimited parallelism can overwhelm CI servers. Set sensible concurrency limits:
{
"command": {
"run": {
"concurrency": 4,
"stream": true,
"bail": true
}
}
}
7. Keep the Root package.json Private
Never forget "private": true in the root package.json. Accidentally publishing the monorepo root is a painful mistake:
{
"name": "my-monorepo",
"private": true
}
8. Centralize Common devDependencies
Place TypeScript, ESLint, Jest, Prettier, and other dev tools in the root devDependencies. Workspace hoisting makes them available to all packages without duplication:
{
"devDependencies": {
"typescript": "^5.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"jest": "^29.0.0",
"prettier": "^3.2.0",
"lerna": "^8.0.0"
}
}
9. Use --since for Efficient CI Pipelines
Combine Lerna with --since to only build, test, and lint packages that actually changed:
// Root package.json scripts
{
"scripts": {
"build:changed": "lerna run build --since=origin/main --stream",
"test:changed": "lerna run test --since=origin/main --stream",
"lint:changed": "lerna run lint --since=origin/main --stream"
}
}
10. Version and Publish in Separate Steps in CI
In automated CI pipelines, separate versioning and publishing into distinct stages with manual approval gates:
# Stage 1: Version bump (creates commit and tag)
lerna version --conventional-commits --yes --no-push
# Manual approval gate in CI
# Stage 2: Publish (pushes tags and publishes to registry)
lerna publish from-package --yes
This prevents unreviewed version bumps from reaching the registry.
Migration Guide: From Legacy Lerna to Modern Lerna
If you're maintaining an older Lerna project that doesn't use workspaces, here's the migration path:
// Step 1: Update lerna.json
{
"useWorkspaces": true,
"npmClient": "yarn"
}
// Step 2: Add workspaces to root package.json
{
"private": true,
"workspaces": ["packages/*"]
}
// Step 3: Remove lerna bootstrap from scripts
// Replace "lerna bootstrap" with "yarn install" or equivalent
// Step 4: Run a fresh install
yarn install
// Step 5: Verify package linking
ls -la node_modules/@myorg
After migration, you'll notice faster installs, less disk usage, and simpler scripts. The lerna bootstrap command becomes unnecessary and can be removed from all CI pipelines and documentation.
Troubleshooting Common Configuration Issues
Issue: Packages Not Found
If Lerna reports "no packages found", verify your glob patterns:
# Debug which packages Lerna discovers
lerna list --all
# Check your patterns
# lerna.json
{
"packages": ["packages/*"] # Only matches one level deep
}
# For nested packages, use recursive patterns
{
"packages": ["packages/**"] # Matches any depth
}
Issue: Symlinks Not Working
With useWorkspaces: true, ensure your root package.json has matching workspaces:
// Must match!
// lerna.json
{ "packages": ["packages/*"] }
// package.json
{ "workspaces": ["packages/*"] } // Same patterns
Issue: Conventional Commits Not Detecting Bumps
Lerna's conventional commit detection relies on proper commit format since the last version tag:
# Check what Lerna sees
lerna version --conventional-commits