Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Configuring pnpm node-linker=hoisted for Incompatible Tools

pnpm's default node_modules layout — a tree of symlinks into a content-addressed store — is what makes it fast and strict. A few tools cannot work with it: React Native's Metro bundler in older setups, some Electron packagers, serverless bundlers that copy node_modules verbatim, and tools that walk the directory tree expecting real folders. For those, pnpm offers node-linker=hoisted, which produces a flat, npm-style node_modules of real directories while keeping pnpm's lockfile and store. This guide explains when you actually need it, how to scope it narrowly, and what strictness you give up.

Symptoms that point at an incompatible tool

The failures usually mention paths inside .pnpm or complain that a package cannot be found despite being installed:

# Metro (React Native), older configurations
error: Error: Unable to resolve module react-native from /repo/apps/mobile/index.js:
react-native could not be found within the project or in these directories:
  node_modules/.pnpm/node_modules
# A packager that copies node_modules into an archive
Error: ENOENT: no such file or directory, open '/tmp/build/node_modules/react/index.js'
# (the archive contained symlinks pointing at the original store location)
# A tool that walks node_modules and ignores symlinks
Warning: 0 packages found in node_modules; license report is empty

Before changing the linker, confirm the tool is really the problem, because the hoisted layout is a workspace-wide change with real costs. Many "pnpm incompatibilities" are phantom dependencies in your own code — covered in Fixing Phantom Dependencies After Switching to pnpm — which should be fixed by declaring dependencies, not by flattening the tree.

How the linkers differ

pnpm supports three layouts, chosen with node-linker. How symlinks and hard links work in each is covered in Workspace Symlinks vs Hard Links.

pnpm node-linker modes Compares the isolated, hoisted and pnp linker modes on layout, strictness, tool compatibility and disk usage. isolated (default) hoisted pnp node_modules layout symlinks into .pnpm flat real folders none (.pnp.cjs) Undeclared imports fail may work fail Tool compatibility most tools highest needs support Disk usage hard links to store hard links, more dirs minimal Lockfile and store pnpm pnpm pnpm
isolated is strict and compact; hoisted trades strictness for compatibility; pnp removes node_modules entirely.

With hoisted, pnpm computes a flat layout much like npm's: each package is placed as high in the tree as possible, with nested copies only for conflicting versions. Files are still hard-linked from the store where the filesystem allows, so installs remain fast and disk-efficient compared with npm.

What the hoisted tree looks like

It helps to see the two layouts side by side for the same dependency set, because the difference explains both the compatibility and the lost strictness.

The same dependencies under isolated and hoisted linkers Left panel shows the isolated layout with symlinks into the .pnpm store; right panel shows the hoisted layout with real top-level folders including the transitive qs package. isolated (default) node_modules/ axios -> .pnpm/axios@1.7.9/... react -> .pnpm/react@18.3.1/... .pnpm/ axios@1.7.9/node_modules/qs qs is not importable by the app hoisted node_modules/ axios/ (real folder) react/ (real folder) qs/ (transitive, hoisted) qs importable without declaring it
Hoisting produces real folders at the top level — including transitive packages your code never declared.

Tools that copy or walk node_modules see ordinary folders in the hoisted layout, which is why they work. Your own code also sees qs, which is why phantom-dependency checks matter again.

Performance and disk usage

Switching linkers changes install behaviour in measurable ways. The hoisted layout still hard-links files from pnpm's store, so the bytes on disk are shared with other projects on the same machine just as with the isolated layout. What grows is the number of directory entries and the time spent creating them, because each hoisted package is a real directory tree rather than a single symlink. On large repositories, expect installs to be somewhat slower than with the isolated layout but still faster than npm, and expect node_modules scans by tools such as editors and file watchers to be heavier, because they now traverse real folders instead of following a few symlinks.

CI caches need adjusting too. With the isolated layout, most teams cache only the pnpm store and reinstall; with the hoisted layout the same approach works, and it remains the right one — do not start caching node_modules itself, because a cached hoisted tree from before a dependency change can hide exactly the resolution differences you need CI to catch.

Configuring the hoisted linker

For the whole workspace:

# pnpm-workspace.yaml (pnpm 10) — or node-linker=hoisted in .npmrc
nodeLinker: hoisted

Then reinstall from scratch so no isolated-layout symlinks remain:

rm -rf node_modules apps/*/node_modules packages/*/node_modules
pnpm install

If only one application needs a flat tree, prefer scoping instead of flattening everything. Two techniques help:

  1. Hoist only what the tool needs. Many tools only need a handful of packages at the root. public-hoist-pattern puts matching packages in the root node_modules while the rest stays isolated:
# .npmrc
public-hoist-pattern[]=react-native
public-hoist-pattern[]=@react-native/*
public-hoist-pattern[]=metro*
  1. Separate the incompatible app. Put the app in its own workspace (a separate pnpm-workspace.yaml at the app folder, excluded from the main workspace) with nodeLinker: hoisted, and consume shared packages through the registry or file: references. That keeps the main workspace strict.
Choosing the least disruptive fix A decision chain from declaring dependencies, to hoisting specific packages, to isolating one app, to switching the whole workspace to hoisted. Is it a phantom import in your code? Declare the dependency no layout change needed yes Does the tool need a few packages at root? public-hoist-pattern hoist only those names yes no Is one app the only consumer? Separate workspace for it hoisted there, strict elsewhere yes no nodeLinker: hoisted whole workspace flat; accept lost strictness no
Change the layout only as much as the incompatible tool requires.

React Native specifics

Modern React Native and Expo releases support pnpm's isolated layout: Metro follows symlinks when resolver.unstable_enableSymlinks is enabled (now the default in recent Metro versions), and Expo's monorepo guidance covers pnpm. Before switching linkers for a React Native app, upgrade Metro and Expo and try the default layout — many of the historic problems are gone. If you still need hoisting, the hoisted linker combined with Metro's watchFolders set to the workspace root is the most reliable configuration:

// apps/mobile/metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('node:path');

const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
  path.resolve(projectRoot, 'node_modules'),
  path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;

What you give up

The hoisted layout brings back npm's weaknesses along with its compatibility:

  • Phantom dependencies work again. Code can import packages it never declared, and the mistake surfaces only when the layout changes. Compensate with lint rules such as import/no-extraneous-dependencies and Knip's unlisted-dependency check.
  • Doppelgangers. When two versions of a package are needed, one is hoisted and the other nested, and which one a given file gets depends on its location — the class of duplicate-instance bugs covered in Deduplicating Duplicate React Versions.
  • Layout changes on unrelated updates. Adding a dependency can change which version is hoisted, altering what undeclared imports resolve to.

Keep those checks in CI whenever the hoisted linker is on, so the loss of strictness does not become a loss of correctness. Record the reason for the setting in a comment next to it, too, so that the next person to review the configuration knows which tool required it and can check whether that is still true.

Worked example: an Electron app in a pnpm monorepo

A team's Electron application is packaged by a tool that copies node_modules into the app bundle. With the isolated layout, the bundle contains symlinks to a store path that does not exist on users' machines, and the packaged app crashes on start. Hoisting only Electron-related packages is not enough, because the packager copies the whole runtime dependency tree. The team moves the Electron app into its own small workspace with nodeLinker: hoisted, consuming three shared libraries from the private registry. The main workspace stays isolated and strict, and the packaged app contains real directories. A CI step now lists symlinks in the packaged output and fails if any remain.

Validation commands

# Which linker is in effect?
pnpm config get node-linker

# Are there symlinks left in node_modules after switching?
find node_modules -maxdepth 2 -type l | head

# Is a package hoisted to the root where the tool expects it?
ls -la node_modules/react-native 2>/dev/null | head -3

Prevention and CI/CD guardrails

  • Scope hoisting as narrowly as possible — specific patterns or a separate workspace before the global setting.
  • Keep dependency-declaration checks in CI when using the hoisted linker.
  • Revisit the decision on tool upgrades; many tools gain symlink support over time.
  • Reinstall from a clean tree when switching linkers, locally and in CI caches.

Frequently Asked Questions

Is node-linker=hoisted the same as shamefully-hoist=true? No. shamefully-hoist keeps the isolated layout but additionally links every package into the root node_modules. hoisted builds a flat layout of real directories. The former keeps symlinks; the latter removes them.

Does the hoisted linker change the lockfile? The resolved versions stay the same; pnpm records the linker setting, so switching it updates lockfile metadata. Commit the lockfile together with the configuration change.

Can different packages use different linkers? Not within one workspace. The linker applies to the whole install. Use separate workspaces if parts of the repository need different layouts.

Will switching to hoisted fix "Invalid hook call" errors? Sometimes it hides them by collapsing two copies into one hoisted copy, but it can also create them when two versions are needed and one ends up nested. Fix duplicate framework copies with overrides or peer dependency cleanup rather than relying on hoisting.

Can I switch back to isolated later? Yes. Change the setting, delete every node_modules folder, reinstall and commit the lockfile. Expect a few phantom imports to surface as errors — fix them by declaring dependencies.

Related

Workspace Symlinks vs Hard Links