Optimizing Monorepos with Yarn Plug'n'Play and Workspaces
Learn how to eliminate node_modules in a JavaScript monorepo using Yarn Berry Plug'n'Play (PnP). This guide covers configuration, workspace execution, and the trade-offs of virtual resolution.
26 Nov 2025, 21:19 UTC

In a large JavaScript or TypeScript monorepo, the node_modules directory often becomes a performance bottleneck. As the number of workspaces grows, the time spent on I/O during npm install or yarn install increases, CI cache sizes balloon, and the "phantom dependency" problem—where a package can import a module it hasn't explicitly declared because it was hoisted to the root—creates unstable builds.
The engineering solution is to move from a physical directory structure to a virtual one using Yarn Berry (v2+) Plug'n'Play (PnP). Instead of copying thousands of files into a local folder, PnP generates a single .pnp.cjs map that tells Node exactly where every package resides in a global cache. When combined with Yarn Workspaces, this eliminates redundant copies of dependencies across your monorepo.
The Shift from Hoisting to Mapping
Traditional monorepos rely on hoisting, where Yarn or NPM attempts to move shared dependencies to the root node_modules to save space. However, this is a heuristic approach that can fail or lead to version conflicts.
With nodeLinker: pnp, Yarn stops creating node_modules entirely. It replaces the filesystem-based lookup with a resolution hook. When your code calls require('lodash'), the PnP loader intercepts the request and points Node to the specific zip archive in the Yarn cache. In a workspace environment, this means that regardless of how many packages depend on a specific version of a library, it is stored once and resolved deterministically via the .pnp.cjs file.
Implementing PnP in a Workspace Environment
To adopt PnP, you must ensure you are using Yarn Berry (v2 or higher). It is recommended to use Corepack to manage the Yarn version to ensure all developers and CI environments use the same binary.
Configuration Example
Create or edit the .yarnrc.yml file at the root of your monorepo to enable PnP and define your workspaces:
# .yarnrc.yml
nodeLinker: pnp
# Ensure the Yarn binary is tracked in the repo for consistency
yarnPath: .yarn/releases/yarn-4.x.x.cjs
# Define workspace patterns
workspaces:
- packages/*
After updating the config, run the following command from the root (requires administrative permissions to write to the global cache if not configured otherwise):
yarn install
Verification: Check your root directory. There should be no node_modules folder, but a .pnp.cjs file should now exist. To verify the linker is active, run yarn config get nodeLinker; it should return pnp.
Executing Scripts and Tooling
Because Node.js does not natively know how to read .pnp.cjs, you cannot run scripts using the standard node index.js command. You must use the Yarn wrapper, which injects the PnP loader into the Node process.
To run a script within a specific workspace or across all workspaces, use the yarn workspaces foreach command. This ensures the environment is correctly bootstrapped for PnP resolution.
Example: Running tests across all workspaces
# Run the 'test' script defined in each package.json
yarn workspaces foreach -A run test
If you need to run a standalone script with Node, use:
yarn node ./scripts/build-assets.js
Trade-offs and Compatibility Limitations
PnP is not a drop-in replacement for node_modules and introduces specific constraints:
- Tooling Support: Some tools (like certain IDE plugins, older versions of ESLint, or specific TypeScript configurations) expect a physical
node_modulesfolder to find type definitions. You may need to install the@yarnpkg/sdksto provide these tools with the necessary "virtual" filesystem access. - Native Modules: Packages with native C++ addons may require specific build steps. While PnP supports them, some legacy build scripts that hardcode paths to
node_moduleswill fail. - Strict Dependencies: PnP forbids "phantom dependencies." If your code imports a package that isn't listed in that specific workspace's
package.json, Yarn will throw an error, even if the package exists elsewhere in the monorepo. While this is a benefit for stability, it can cause initial breakage during migration.
Rollback Procedure
If a critical tool is incompatible with PnP, you can revert to the traditional structure by changing the linker in .yarnrc.yml:
- Change
nodeLinker: pnptonodeLinker: node-modules. - Run
yarn installto regenerate the physicalnode_modulesdirectories. - Delete the
.pnp.cjsand.pnp.loader.mjsfiles.
Actionable Summary
Switching to PnP is a strategic decision to prioritize install speed and dependency correctness over legacy compatibility. To start, pin your Yarn version via Corepack, set nodeLinker: pnp, and migrate your execution scripts to use yarn node or yarn workspaces foreach. If you encounter MODULE_NOT_FOUND errors, check your package.json for missing explicit dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.