Setting Up Yarn Berry Workspaces with the node-modules Linker
Yarn Berry (Yarn 2 and later) is a capable monorepo tool, but its default Plug'n'Play install strategy breaks tools that expect a node_modules directory, and many teams abandon it on day one because of that. Configured with the node-modules linker, Yarn Berry behaves like a conventional workspace manager while keeping its strengths: the workspace: protocol, constraints, yarn workspaces foreach, and fast, deterministic installs. This guide sets up a Yarn Berry workspace from scratch, explains each configuration choice, and wires it into CI.
When this setup is the right choice
Yarn Berry with node-modules suits teams that already know Yarn, want features such as constraints and the foreach command, and need maximum compatibility with React Native, older build tools, or IDE integrations that scan node_modules. If you are choosing a workspace manager from scratch with no existing preference, Workspace Configuration Deep Dive compares npm, pnpm and Yarn in detail; pnpm's strict layout is often the stronger default.
The symptoms that usually send people here come from Plug'n'Play:
Error: Your application tried to access typescript, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.
Error: Cannot find module 'eslint-plugin-react' — Require stack: <none> (PnP resolution failed)
Those messages are accurate — they flag undeclared imports — but a large existing codebase may not be ready to fix them all at once. The node-modules linker is the pragmatic starting point.
Initialising the workspace
corepack enable
mkdir acme && cd acme
yarn init -2 # creates package.json, .yarnrc.yml and a Yarn 4 setup
yarn set version stable # pins the latest stable Yarn in packageManager
mkdir -p apps packages
Declare the workspace globs and the package manager in the root manifest:
{
"name": "acme",
"private": true,
"packageManager": "yarn@4.6.0",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "yarn workspaces foreach -A --topological-dev --parallel run build",
"test": "yarn workspaces foreach -A --parallel run test",
"lint": "yarn workspaces foreach -A --parallel run lint"
}
}
Then configure Yarn itself in .yarnrc.yml:
nodeLinker: node-modules
enableTelemetry: false
enableGlobalCache: true
nmHoistingLimits: workspaces
npmScopes:
acme:
npmRegistryServer: "https://npm.pkg.github.com"
npmAuthToken: "${NODE_AUTH_TOKEN:-}"
Each setting earns its place:
nodeLinker: node-modulesproduces a conventionalnode_modulestree instead of.pnp.cjs.enableGlobalCache: truestores downloaded archives in a shared machine-wide cache instead of.yarn/cachein the repository, which suits teams that do not want to commit zero-install archives.nmHoistingLimits: workspacesstops dependencies being hoisted above the workspace that declares them. It is the setting that most reduces phantom dependencies under thenode-moduleslinker, at the cost of a little extra disk space.npmScopesroutes a private scope to its registry with a token read from the environment.
Linking workspaces with the workspace protocol
Inside the workspace, depend on sibling packages with workspace: ranges. Yarn links them locally and rewrites the range to a real version when you publish:
{
"name": "@acme/web",
"private": true,
"dependencies": {
"@acme/ui": "workspace:^",
"react": "^18.3.1"
}
}
workspace:^ becomes ^1.4.0 in the published manifest if @acme/ui is at 1.4.0; workspace:* becomes the exact version. The semantics are shared with pnpm and covered in Using the workspace: Protocol Correctly. Add a dependency to one workspace from the root with:
yarn workspace @acme/web add @acme/ui@workspace:^
yarn workspace @acme/ui add -D typescript
Running tasks across workspaces
yarn workspaces foreach is Yarn's built-in task runner. The flags that matter:
| Flag | Meaning |
|---|---|
-A / --all |
run in every workspace |
--topological-dev |
wait for dependencies (including dev dependencies) to finish first |
-p / --parallel |
run independent workspaces concurrently |
--since |
only workspaces changed since the default branch |
-R / --recursive |
the current workspace and its dependencies |
--include / --exclude |
glob filters by workspace name |
# Build everything changed since main, in dependency order
yarn workspaces foreach --since --topological-dev -p run build
# Test one app and everything it depends on
yarn workspace @acme/web exec -- yarn workspaces foreach -R --topological-dev run test
foreach has no caching. For large repositories, put Turborepo or Nx on top: both read Yarn workspaces directly and add a task graph with local and remote caching. That trade-off is covered in Choosing a Monorepo Task Runner.
Enforcing rules with constraints
Yarn 4 constraints are JavaScript rules, in yarn.config.cjs, that check and fix every workspace manifest. They are the feature most teams choose Yarn Berry for:
// yarn.config.cjs
module.exports = {
async constraints({ Yarn }) {
// Every workspace must use the same version of each external dependency
for (const dep of Yarn.dependencies()) {
if (dep.type === 'peerDependencies') continue;
for (const other of Yarn.dependencies({ ident: dep.ident })) {
if (other.type === 'peerDependencies') continue;
dep.update(other.range);
}
}
// Every public package must declare a license
for (const ws of Yarn.workspaces()) {
if (!ws.manifest.private) ws.set('license', 'MIT');
}
},
};
Run yarn constraints in CI to report violations and yarn constraints --fix locally to apply them. Aligned versions across workspaces reduce duplicate installs and the conflicts described in Keeping Workspace Dependency Versions in Sync with syncpack.
Migrating an existing Yarn Classic workspace
Most Yarn Berry workspaces start life as Yarn 1 repositories. The upgrade path is short if you take it in order.
- Pin the new version and linker first. Run
yarn set version stablein the repository root, then create.yarnrc.ymlwithnodeLinker: node-modulesbefore running any install. Installing first with the default Plug'n'Play linker produces a wall of errors that have nothing to do with the upgrade itself. - Convert the lockfile. The first
yarn installreads the Yarn 1yarn.lockand rewrites it in the Berry format, keeping resolved versions where it can. Commit the result on its own so the diff is reviewable as "format change, same versions". - Replace removed commands. Yarn Classic's
yarn workspaces run buildbecomesyarn workspaces foreach -A run build;yarn global addno longer exists (useyarn dlxfor one-off tools); lifecycle scripts such aspreinstallin workspaces behave slightly differently, so check any that do real work. - Move configuration. Settings in
.yarnrcor.npmrcthat Yarn 1 honoured — registries, auth tokens,ignore-engines— must be restated in.yarnrc.ymlusing Berry's option names (npmRegistryServer,npmAuthToken, and so on). Berry ignores.npmrcfor its own requests. - Update CI. Replace
yarn install --frozen-lockfilewithyarn install --immutable, addcorepack enable, and change the cache path from Yarn 1's cache folder to the Berry global cache.
Plugins are the last consideration. Yarn 4 bundles the commonly used plugins — workspace-tools, interactive-tools, typescript — so the yarn plugin import lines from Yarn 2 and 3 era guides are usually unnecessary and can be removed from .yarnrc.yml.
CI/CD integration
name: ci
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # --since needs history
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: corepack enable # uses packageManager from package.json
- uses: actions/cache@v4
with:
path: ~/.yarn/berry/cache
key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
- run: yarn install --immutable # fails on lockfile drift (YN0028)
- run: yarn constraints
- run: yarn workspaces foreach --since --topological-dev -p run build
- run: yarn workspaces foreach --since -p run test
The cache path matches enableGlobalCache: true. The immutable install turns any uncommitted lockfile change into a failure, as described in Fixing Yarn 'The Lockfile Would Have Been Modified' (YN0028).
Pitfalls
| Mistake | Impact | Remediation |
|---|---|---|
| Leaving the default PnP linker | Tools that scan node_modules fail |
Set nodeLinker: node-modules |
| Global Yarn instead of Corepack | Lockfile metadata churn, YN0028 in CI | corepack enable and packageManager |
| Default hoisting | Phantom dependencies work locally, break elsewhere | nmHoistingLimits: workspaces |
foreach without --topological-dev |
Apps build before their libraries | Add the flag to build scripts |
Committing .yarn/cache by accident |
Huge diffs, partial caches | Git-ignore it unless using zero-installs |
Frequently Asked Questions
Can I switch to Plug'n'Play later?
Yes. Change nodeLinker to pnp, run yarn install, and fix the undeclared-import errors Yarn reports. Doing it package by package with pnpMode: loose as a bridge keeps the repository working during the move.
Is yarn workspaces foreach enough for a large monorepo? It orders and parallelises tasks but does not cache them. Past a few dozen packages, adding Turborepo or Nx for caching usually pays for itself quickly.
Do I need to commit .yarn/releases?
Not when Corepack is enabled and packageManager is set. Committing the release file is an older pattern that guarantees the version even without Corepack, at the cost of a large binary-like file in the repository.
How do I run a one-off CLI without adding it as a dependency?
Use yarn dlx, the Berry replacement for npx and yarn global add. It downloads the package into a temporary environment, runs it, and leaves no trace in your manifests or lockfile — for example, yarn dlx create-vite my-app.
Why does my IDE not find types after switching linkers?
Editors cache module resolution. After changing nodeLinker, restart the TypeScript server and delete any leftover .pnp.cjs and .pnp.loader.mjs files, which some editors pick up before they look at node_modules.
Related
- Workspace Configuration Deep Dive compares workspace setups across package managers.
- Migrating from Yarn 1 to pnpm Workspaces is the alternative path for Yarn Classic users.
- Fixing Yarn 'The Lockfile Would Have Been Modified' (YN0028) handles the most common CI failure in this setup.
- Using the workspace: Protocol Correctly explains how local ranges are rewritten on publish.