Modern JavaScript development often involves managing interconnected libraries, microservices, or applications within a single repository. When dependencies spiral across dozens of packages, traditional approaches—like manual npm install
in each subfolder—become unsustainable. Let's explore how Yarn Workspaces and Lerna optimize this workflow, reducing redundancy and accelerating builds.
The Monorepo Dependency Trap
Imagine a monorepo with three packages:
monorepo/
├── package-a
├── package-b
│ └── (depends on package-a)
└── package-c
└── (depends on package-b)
A naïve approach:
- Run
npm install
in each package. package-b
installspackage-a
as a dependency.package-c
installspackage-b
, which re-installspackage-a
again.
Result: Duplicate node_modules
, wasted disk space, inconsistent installs, and circular dependency nightmares.
Yarn Workspaces: The Dependency Unifier
Yarn Workspaces solves this via symlinking and hoisting. Add this to monorepo/package.json
:
{
"name": "monorepo",
"private": true,
"workspaces": ["package-a", "package-b", "package-c"]
}
Now:
- Run
yarn install
once at the root. - Yarn creates a single root-level
node_modules
. - Dependencies are hoisted: Shared packages like
lodash
install at the root once. - Local packages (e.g.,
package-a
) become symlinks.
Workspace Dependency Syntax:
In package-b/package.json
, reference package-a
like this:
{
"dependencies": {
"package-a": "workspace:*"
}
}
No version numbers. This links the local package dynamically.
Lerna: The Workflow Automator
Workspaces handles installation; Lerna automates scripting across packages. Install with:
yarn global add lerna
lerna init
Key Features:
-
Cross-Package Commands:
bashlerna run build # Runs `build` script in all packages lerna run test --scope=package-a # Only in package-a
-
Dependency Hoisting Control:
Prevent conflicting nestednode_modules
with:json{ "npmClient": "yarn", "useWorkspaces": true }
(In
lerna.json
) -
Version Management:
bashlerna version --conventional-commits # Detect version bumps via commit messages lerna publish # Publish changed packages
Cross-Workspace Code Sharing: Optimization Secrets
Rule 1: Avoid Dependency Duplication
Situation: package-a
and package-b
both require react@18
. Yarn hoists it, but what if package-c
needs react@17
?
Fix: Specify resolutions
at the root to enforce consistency:
{
"resolutions": {
"react": "18"
}
}
Rule 2: Compilation Acceleration
Leverage caching and parallelism:
lerna run build --concurrency=4 --stream
# Use toolchains like Turborepo/Nx for incremental builds
Rule 3: Tree-Shaking
Prevent workspace symlinks from bloating production bundles:
- Use bundlers like Webpack’s
symlinks: false
to resolve real paths. - In
package.json
, specifyfiles
:json{ "files": ["dist", "package.json"] }
Debugging Common Pitfalls
Error: Package not found "package-a"
Solution:
- Ensure
workspaces
array in rootpackage.json
matches subdirectory names. - Ensure
package-b
callspackage-a: workspace:*
. - Run
yarn install --check-files
to refresh symlinks.
Error: Inconsistent peerDependencies
Fix: Hoisted dependencies can hide peer conflicts. Use:
yarn check --verify-tree
Or via Lerna:
lerna run --concurrency=1 build # Serial to expose race conditions
Final Recommendation: When to Use This Stack
- You manage ≥3 interdependent packages.
- Teams touch multiple projects daily.
- Sharing utilities/configs across services is common.
Alternatives:
pnpm
: Strict dependency isolation + disk efficiency.Turborepo
: Caching-first builds (complements Workspaces/Lerna).
Critical Mindset Shift: Treat the monorepo as a shared immutable system. Never manually edit node_modules
symlinks. Enforce:
- One lockfile (
yarn.lock
) - Auto-updated CI workflows
- Lerna-managed cross-package versions
By unifying dependency trees and automating workflows, this toolchain reduces cognitive load and CI costs. The result? Faster iterations without sacrificing reliability.