Fixing Phantom Dependencies After Switching to pnpm
A project that installed and ran fine with npm or Yarn Classic often breaks the moment it moves to pnpm, with Cannot find module errors for packages that are plainly installed. Those packages are phantom dependencies: code imports them, but no package.json in the importing package declares them. npm's flat node_modules layout hid the mistake; pnpm's strict layout exposes it. This guide shows how to find every phantom import, fix them properly, and use pnpm's escape hatches only where a third-party package leaves you no choice.
Exact symptoms and error messages
The errors look like missing installs, which is what makes them confusing. At runtime:
Error: Cannot find module 'lodash'
Require stack:
- /repo/packages/api/src/utils/format.js
- /repo/packages/api/src/server.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'date-fns' imported from /repo/apps/web/src/lib/time.ts
In a bundler:
✘ [ERROR] Could not resolve "qs"
src/client/http.ts:1:22:
1 │ import { stringify } from "qs";
You can mark the path "qs" as external to exclude it from the bundle
And from TypeScript, whose resolver follows the same rules:
error TS2307: Cannot find module 'lodash' or its corresponding type declarations.
The tell-tale sign is that lodash or qs is in node_modules/.pnpm/ — it was installed as a dependency of something else, such as an HTTP client or a UI framework — but it is not reachable from the importing package.
Root cause analysis
npm and Yarn Classic hoist every package they can to the top-level node_modules, so any file in the project can require any installed package, declared or not. pnpm instead gives each package a node_modules directory containing only its declared dependencies, symlinked into a content-addressed store. The mechanics are covered in Dependency Resolution Explained and Workspace Symlinks vs Hard Links.
Node.js resolves a bare specifier by walking up from the importing file and checking each node_modules directory. With pnpm, the walk from packages/api/src/ finds packages/api/node_modules/ (declared dependencies only) and then the root node_modules/ (root dependencies plus anything hoisted by public-hoist-pattern). A transitive dependency lives several levels down inside .pnpm/, where that walk never reaches.
Phantom dependencies are a real bug, not a pnpm quirk. Your code depends on a version of qs chosen by axios's range, so an axios patch release that drops or upgrades qs can break you without any change to your own manifest.
Resolution and configuration patch
Fix phantom imports by declaring them. Reserve configuration workarounds for third-party packages with their own phantom imports.
- Declare every package your code imports. In the package that contains the import:
pnpm add qs lodash --filter @acme/api
pnpm add -D @types/lodash --filter @acme/api
Pin to the same major version the transitive copy used so behaviour does not change during the migration; pnpm why qs shows which version is currently installed.
- Patch third-party manifests with
packageExtensions. When a published package imports something it forgot to declare — common with plugins that assume a peer is installed — add the missing entry without forking it:
# pnpm-workspace.yaml (pnpm 10) — or "pnpm.packageExtensions" in the root package.json
packageExtensions:
"some-eslint-plugin@*":
dependencies:
"eslint-utils": "^3.0.0"
"legacy-ui-kit@2":
peerDependencies:
"react-dom": "*"
- Hoist narrowly for tools that scan
node_modules. A few tools — some ESLint plugin loaders, older React Native tooling, IDE integrations — look for packages at the root regardless of declarations. Hoist only those names:
# .npmrc
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
- Avoid
shamefully-hoist=trueandnode-linker=hoistedexcept as a temporary bridge. Both recreate the flat layout and bring phantom dependencies back for the whole repository. If you truly need a flat tree for one incompatible tool, see Configuring pnpm node-linker=hoisted for Incompatible Tools.
Finding every phantom import before users do
Fixing errors one at a time as they appear is slow, because each run stops at the first missing module. Scan the codebase instead. Knip reports imports of packages that are not listed in the importing workspace's manifest:
npx knip --include unlisted
ESLint can enforce the rule permanently with import/no-extraneous-dependencies, which fails on any import that is not declared in the nearest package.json:
{
"rules": {
"import/no-extraneous-dependencies": ["error", {
"devDependencies": ["**/*.test.ts", "**/*.config.ts", "scripts/**"]
}]
}
}
Both tools understand workspaces, so each package is checked against its own manifest rather than the root. The broader tooling for unused and unlisted dependencies is covered in Finding Unused Dependencies and Exports with Knip.
Test files, configuration and scripts
Phantom imports gather in places reviewers rarely look: test helpers importing @testing-library/dom that only arrived with @testing-library/react, a vite.config.ts importing @vitejs/plugin-react from the root, or a release script importing semver that came with some other tool. These belong in devDependencies of the package that owns the file. Root-level configuration files should import only from root devDependencies.
Worked example: migrating a ten-package workspace
A realistic migration shows the order that keeps the repository green throughout. Take a workspace with three applications and seven libraries that has used npm workspaces for three years.
Day one — switch the tool, not the rules. Add pnpm-workspace.yaml, run pnpm import to convert package-lock.json into pnpm-lock.yaml with the same resolved versions, and temporarily set shamefully-hoist=true in .npmrc. The repository now behaves almost exactly as before, which lets you land the switch without a flood of failures. Verify with a full build and test run.
Days two to four — declare, package by package. Remove shamefully-hoist locally (not yet in the committed .npmrc), run each package's build and tests, and add every missing declaration the errors reveal. Knip's unlisted report gives you the list up front, so each package becomes one focused pull request. Libraries go first, because applications import them and their fixes cascade.
Day five — remove the bridge. Delete shamefully-hoist from .npmrc, add import/no-extraneous-dependencies to the shared ESLint config, and make Knip's unlisted check blocking in CI. Any remaining third-party phantom imports get packageExtensions entries with a comment linking to the upstream issue.
Version selection is the one thing to watch during the declare phase. When you add qs to a package that previously received it through axios, pnpm resolves the newest version that satisfies the range you give it. Adding "qs": "*" or a caret range on a newer major can silently upgrade behaviour. Use the exact version pnpm why reported, with a caret on that version, so the declared dependency matches what the code was already running against.
Why strictness pays off beyond the migration
Declared dependencies make three later jobs easier. Dependency update bots such as Renovate see every package you actually use, so security fixes for qs reach you directly instead of waiting on an axios release. Task runners that compute affected packages — Turborepo, Nx, pnpm's own --filter — rely on declared dependencies to build the package graph, so undeclared imports produce wrong "affected" results and stale caches. And publishing becomes safe: a library that imports an undeclared package works in your monorepo but fails for every consumer, because consumers install only what the manifest lists.
CLI validation and debug commands
# Why is this package installed, and who depends on it?
pnpm why qs --recursive
# Is it resolvable from a specific package?
cd packages/api && node -e "console.log(require.resolve('qs'))"
# List everything each workspace package declares
pnpm list --recursive --depth 0
# Confirm there are no unlisted imports left
npx knip --include unlisted --no-exit-code | tee knip.txt
# Full clean reinstall to prove nothing depends on a leftover flat tree
rm -rf node_modules packages/*/node_modules && pnpm install --frozen-lockfile && pnpm -r test
The clean reinstall matters. After switching package managers, an old flat node_modules from npm can linger in a package directory and make phantom imports resolve on your machine while CI fails.
Prevention and CI/CD guardrails
- Keep strict mode. Leave pnpm's defaults in place and reject pull requests that add
shamefully-hoist=truewithout a written reason. - Enforce declarations in lint.
import/no-extraneous-dependenciesin CI stops new phantom imports at review time. - Run Knip in CI with
--include unlistedas a blocking check. - Install from scratch in CI with
pnpm install --frozen-lockfile, never reusing a cachednode_modulesdirectory — cache the pnpm store instead, as in Caching the pnpm Store in GitHub Actions. - Upstream your
packageExtensions. Each entry is a bug in someone else's package; open an issue or pull request so the workaround can be deleted.
Frequently Asked Questions
Why does the code work locally but fail in CI after the pnpm migration?
Usually because a stale npm-era node_modules directory still exists inside a package on your machine, so Node.js finds the phantom package there. CI starts clean. Delete every node_modules directory and reinstall to reproduce CI locally.
Is shamefully-hoist ever the right answer? As a one-week bridge during a large migration, it can be. It makes the repository behave like npm while you fix declarations package by package. Leaving it on permanently throws away the main correctness benefit of pnpm.
Do Yarn Berry and Bun have the same problem?
Yarn Berry's Plug'n'Play mode is even stricter and reports undeclared imports with a clear error naming the package. Bun and Yarn with the node-modules linker hoist like npm, so phantom imports still work there — until you switch tools.
Related
- Dependency Resolution Explained walks through how each package manager builds the installed tree.
- Migrating from Yarn 1 to pnpm Workspaces covers the full migration in which these errors usually appear.
- Detecting Undeclared Cross-Package Imports applies the same discipline to imports between workspace packages.
- Configuring pnpm node-linker=hoisted for Incompatible Tools explains when a flat layout is justified.