Yarn Workspaces: Built-in Monorepo Management Without the Tooling Tax
Yarn Workspaces gives you a single lockfile, instant cross-package symlinks, and topological script execution—no Lerna, no Turborepo, no extra config. Here's how to set it up, control hoisting for stubborn tools, and optimize CI with focus installs.
12 May 2026, 01:38 UTC

The Problem: Dependency Drift Across Packages
You have a repository with three packages: a shared UI library, a backend API, and a CLI tool. Each has its own package.json and node_modules. When you update a transitive dependency in one package, the others don't know. CI passes locally but fails in production because versions drifted. You could add Lerna or Turborepo, but that's another moving part to maintain.
Yarn Workspaces solves this natively. A single yarn.lock at the repo root governs every workspace. Yarn symlinks your local packages into each other's node_modules during install, so changes are instant—no npm link, no publishing, no extra tooling.
How It Works: One Lockfile, Symlinked Packages
Declare workspaces in the root package.json:
{
"private": true,
"workspaces": ["packages/*"]
}
Run yarn install once. Yarn creates a single yarn.lock and hoists shared dependencies to the top-level node_modules (Classic) or generates a .pnp.cjs map (Yarn 2+/4 with Plug'n'Play). Each workspace gets a symlink so import 'ui-lib' resolves to packages/ui-lib immediately.
This means deterministic versions across the board. If ui-lib and api both depend on lodash@4.17.21, you get one copy. If they need different versions, Yarn nests appropriately—no manual resolution files.
Controlling Hoisting When Tools Break
Some packages assume a physical node_modules layout. React Native, TypeScript project references, and certain native addons break when hoisted too aggressively.
In Yarn 1 (Classic), use workspaces.nohoist:
{
"workspaces": {
"packages": ["packages/*"],
"nohoist": ["**/react-native", "**/react-native/**"]
}
}
In Yarn 2+/4, use packageExtensions in .yarnrc.yml to declare missing peer dependencies or force local resolution:
packageExtensions:
"react-native@*":
peerDependencies:
"react": "*"
"@myorg/ui@*":
dependencies:
"typescript": "workspace:*"
This keeps the default hoisting benefits while carving out exceptions for the few packages that need them.
Worked Example: Two-Package Workspace in 30 Seconds
Run these commands in a fresh directory (requires Node 18+ and Yarn 4.x):
mkdir demo && cd demo
corepack enable && corepack prepare yarn@4.5.0 --activate
yarn init -w
mkdir -p packages/a packages/b
cat > packages/a/package.json <<'EOF'
{"name": "a", "version": "1.0.0", "main": "index.js"}
EOF
cat > packages/b/package.json <<'EOF'
{"name": "b", "version": "1.0.0", "dependencies": {"a": "workspace:*"}}
EOF
yarn install
ls -la node_modules/ | grep -E 'a|b'
You'll see a -> ../../packages/a and b -> ../../packages/b symlinks. Edit packages/a/index.js and require('a') in b picks up the change instantly.
Run a script across both packages in topological order:
echo '{"scripts":{"build":"echo building $npm_package_name"}}' > packages/a/package.json
echo '{"scripts":{"build":"echo building $npm_package_name"}}' > packages/b/package.json
yarn workspaces run build
Output shows a builds before b because b depends on a.
CI Optimization: Focus Installs
In CI, you rarely need every workspace. yarn workspaces focus api --production creates a minimal install containing only api and its transitive dependencies. This shrinks Docker layers and speeds up cache restoration.
For zero-installs (commit the cache), add to .yarnrc.yml:
enableGlobalCache: false
nodeLinker: pnp
# commit .yarn/cache and .pnp.cjs to git
Then yarn install --immutable in CI verifies the lockfile matches committed artifacts—no network calls.
Trade-offs and Gotchas
- Plug'n'Play default: Yarn 2+ removes
node_modules. Legacy tools (Jest <29, older Webpack, some native modules) expect a physical tree. Fix:nodeLinker: node-modulesin.yarnrc.yml. - Phantom dependencies: A package
requires a transitive dependency it never declared. Hoisting hides this locally but breaks in consumers. EnablenmHoistingLimits: workspacesor add missing deps viapackageExtensions. - Workspace protocol in published packages:
"a": "workspace:*"works locally but is invalid on npm. Before publishing, replace with a real version (tools like Changesets automate this). Forgetting ships broken version ranges. - Yarn 1 is maintenance-only: New projects should start on Yarn 4.x for security updates and Node 20+ support.
When to Adopt
Use Yarn Workspaces when:
- You have 2+ packages that share code or dependencies
- You want deterministic installs without a separate monorepo tool
- Your team can handle PnP migration or opts into
node-moduleslinker
Skip it when:
- Packages have truly independent release cycles and ownership
- You rely on tooling that doesn't support PnP and can't switch linker
- The repo is small enough that separate repos are simpler
Verify It Works
After setup, run:
yarn --version # confirm 4.x
yarn workspaces list # shows all workspaces and dependencies
grep -c '^"' yarn.lock # single lockfile entry count
yarn workspaces focus a --production && du -sh .yarn/cache # focus size check
If the symlinks appear, scripts run in topological order, and the lockfile stays single, you're set. The rest is layering release automation on top.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.