Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

Yarn Berry linkers compared with pnpm Compares Yarn Plug'n'Play, Yarn with the node-modules linker, and pnpm on tool compatibility, strictness, install speed and disk usage. Yarn PnP Yarn node-modules pnpm Tool compatibility needs SDKs and patches highest high Undeclared imports always an error allowed if hoisted error by default node_modules folder none yes yes, symlinked Disk usage zip archives only full copies hard-linked store Install speed fastest moderate fast
The node-modules linker trades some strictness for compatibility; pnpm offers strictness with a real node_modules directory.

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-modules produces a conventional node_modules tree instead of .pnp.cjs.
  • enableGlobalCache: true stores downloaded archives in a shared machine-wide cache instead of .yarn/cache in the repository, which suits teams that do not want to commit zero-install archives.
  • nmHoistingLimits: workspaces stops dependencies being hoisted above the workspace that declares them. It is the setting that most reduces phantom dependencies under the node-modules linker, at the cost of a little extra disk space.
  • npmScopes routes a private scope to its registry with a token read from the environment.
Layout of a Yarn Berry workspace with the node-modules linker Directory tree showing the root manifest, yarnrc, lockfile, apps and packages folders, and node_modules folders at the root and per workspace. acme/ package.json workspaces, packageManager .yarnrc.yml nodeLinker, hoisting, scopes yarn.lock single lockfile for all workspaces apps/web/ node_modules/ web's declared deps packages/ui/ package.json name: @acme/ui node_modules/ root tooling and links to workspaces
With nmHoistingLimits set to workspaces, each workspace keeps its own node_modules for the dependencies it declares.

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.

Topological build order computed by foreach A dependency graph where tsconfig and utils build first, ui and api-client next, and the web app last, as yarn workspaces foreach --topological-dev orders them. @acme/tsconfig @acme/utils @acme/ui @acme/api-client @acme/web
--topological-dev waits for each workspace's dependencies, so shared packages always build before the apps that use them.

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.

  1. Pin the new version and linker first. Run yarn set version stable in the repository root, then create .yarnrc.yml with nodeLinker: node-modules before 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.
  2. Convert the lockfile. The first yarn install reads the Yarn 1 yarn.lock and 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".
  3. Replace removed commands. Yarn Classic's yarn workspaces run build becomes yarn workspaces foreach -A run build; yarn global add no longer exists (use yarn dlx for one-off tools); lifecycle scripts such as preinstall in workspaces behave slightly differently, so check any that do real work.
  4. Move configuration. Settings in .yarnrc or .npmrc that Yarn 1 honoured — registries, auth tokens, ignore-engines — must be restated in .yarnrc.yml using Berry's option names (npmRegistryServer, npmAuthToken, and so on). Berry ignores .npmrc for its own requests.
  5. Update CI. Replace yarn install --frozen-lockfile with yarn install --immutable, add corepack 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