pnpm node-linker=hoisted: a flat node_modules like npm
- tool
- pnpm
- file
- pnpm-workspace.yaml as nodeLinker (pnpm 10+, the only place from pnpm 11), or .npmrc as node-linker (pnpm 8 – 10)
- key
- node-linker
- type
- isolated | hoisted | pnp
- default
- isolated
node-linker decides how pnpm lays out node_modules. The default, isolated, puts only your direct dependencies at the top of node_modules, as symlinks into node_modules/.pnpm, so code cannot import packages it did not declare. node-linker=hoisted creates a flat node_modules without symlinks, like npm and Yarn Classic, for tools that do not follow symlinks (React Native's Metro in older versions, some Electron packagers, serverless bundlers). pnp uses Plug'n'Play with no node_modules.
Isolated vs hoisted
cat > package.json <<'EOF'
{ "name": "app", "version": "1.0.0", "dependencies": { "is-odd": "3.0.1" } }
EOF
npx --yes pnpm@9 install > /dev/null 2>&1
echo "--- isolated (default): node_modules"
ls -A node_modules
rm -rf node_modules pnpm-lock.yaml
echo "node-linker=hoisted" > .npmrc
npx --yes pnpm@9 install > /dev/null 2>&1
echo "--- hoisted: node_modules"
ls -A node_modulesOutput
--- isolated (default): node_modules .modules.yaml .pnpm is-odd --- hoisted: node_modules .modules.yaml .pnpm is-number is-odd
is-number is a dependency of is-odd. With isolated it is not visible at the top level, so require("is-number") from your code fails, which protects you from undeclared ("phantom") dependencies. With hoisted it sits next to is-odd and the import works, as in npm.
pnpm 11: set it in pnpm-workspace.yaml
pnpm 10 accepts the setting both as node-linker in .npmrc and as nodeLinker in pnpm-workspace.yaml. pnpm 11 reads only auth and registry settings from .npmrc; layout settings such as nodeLinker, hoistPattern, publicHoistPattern and shamefullyHoist must be in pnpm-workspace.yaml, and the .npmrc line is silently ignored:
cat > package.json <<'EOF'
{ "name": "app", "version": "1.0.0", "dependencies": { "is-odd": "3.0.1" } }
EOF
echo "node-linker=hoisted" > .npmrc
npx --yes pnpm@11 install > /dev/null 2>&1
echo "--- pnpm 11, .npmrc: is-number hoisted?"
ls node_modules | grep -c '^is-number$'
rm -rf node_modules pnpm-lock.yaml .npmrc
echo "nodeLinker: hoisted" > pnpm-workspace.yaml
npx --yes pnpm@11 install > /dev/null 2>&1
echo "--- pnpm 11, pnpm-workspace.yaml: is-number hoisted?"
ls node_modules | grep -c '^is-number$'Output
--- pnpm 11, .npmrc: is-number hoisted? 0 --- pnpm 11, pnpm-workspace.yaml: is-number hoisted? 1
pnpm-workspace.yaml works for single-package projects too, so put the setting there on pnpm 10 as well; it keeps working after an upgrade.
Values
| Value | Layout | Use it when |
|---|---|---|
| isolated | Direct dependencies symlinked into node_modules/.pnpm; content-addressable store with hard links | Default; strict and disk-efficient |
| hoisted | Flat node_modules, no symlinks | A tool or runtime breaks on symlinks |
| pnp | No node_modules; a .pnp.cjs resolver | You already use Plug'n'Play tooling |
Softer alternatives
Before switching the whole project to hoisted, try these settings (in .npmrc up to pnpm 10; in pnpm 11 the same keys go in pnpm-workspace.yaml as publicHoistPattern and shamefullyHoist):
# hoist matching packages to the top-level node_modules (default: none)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
# or make every package visible at the top level, keeping the symlinked layout
shamefully-hoist=trueWhich value to choose
- Keep isolated whenever the project works with it. The strict layout catches phantom dependencies: an import of a package you never declared fails locally instead of breaking later when a transitive dependency changes.
- Try the softer options first when one tool needs a package at the top level, such as an ESLint plugin or a type package:
public-hoist-patternexposes only what matches. - Use hoisted for runtimes, bundlers and packagers that resolve files without following symlinks, or when migrating a large npm project in steps.
- Use pnp only if your tools support Plug'n'Play resolution; many editors and native modules need extra setup.
After a layout change, errors like Cannot find module usually mean code imports a package that is not declared in its own package.json; add it as a dependency rather than relying on hoisting.
FAQ
Why does pnpm ignore node-linker=hoisted in my .npmrc?
From pnpm 11, .npmrc is read only for auth and registry settings, and settings that shape node_modules can only be set in pnpm-workspace.yaml. Move it there as nodeLinker: hoisted and reinstall.
Does hoisted lose pnpm's disk savings?
No. Packages still come from the content-addressable store and are hard-linked into node_modules; only the folder layout changes.
Should I use shamefully-hoist or node-linker=hoisted?
shamefully-hoist keeps the symlinked layout but exposes every package at the top level, which is enough for most code that imports undeclared packages. Use hoisted when a tool cannot follow symlinks at all.