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.
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.
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:
- Hoist only what the tool needs. Many tools only need a handful of packages at the root.
public-hoist-patternputs matching packages in the rootnode_moduleswhile the rest stays isolated:
# .npmrc
public-hoist-pattern[]=react-native
public-hoist-pattern[]=@react-native/*
public-hoist-pattern[]=metro*
- Separate the incompatible app. Put the app in its own workspace (a separate
pnpm-workspace.yamlat the app folder, excluded from the main workspace) withnodeLinker: hoisted, and consume shared packages through the registry orfile:references. That keeps the main workspace strict.
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-dependenciesand 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 explains pnpm's layouts and link types.
- Fixing Broken Symlinks in pnpm node_modules covers symlink problems that do not need a linker change.
- Fixing Phantom Dependencies After Switching to pnpm rules out the most common false alarm.
- Resolving Symlinked Workspace Packages in Jest and Vitest handles test runners that struggle with symlinks.