Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

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.

Flat hoisting versus pnpm's strict layout Left panel shows npm's flat node_modules where a transitive dependency qs is reachable from the app; right panel shows pnpm where qs lives only under its parent package. npm / Yarn Classic (flat) node_modules/ axios/ (declared) qs/ (transitive, hoisted) lodash/ (transitive, hoisted) import 'qs' -> works by accident pnpm (strict) node_modules/ axios -> .pnpm/axios@1.7.9/... .pnpm/ axios@1.7.9/node_modules/ qs -> ../../qs@6.13.0/... import 'qs' -> Cannot find module
npm's flat tree makes transitive packages importable by accident; pnpm only links what each package declares.

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.

Choosing the right fix for a phantom dependency A decision chain from whether the import is in your code, to whether a third-party package is missing a declaration, to hoisting as a last resort. Is the import in your own code? Add it to dependencies pnpm add qs --filter @acme/api yes Is a dependency missing a declaration? Use packageExtensions patch its manifest in pnpm-workspace.yaml or package.json yes no Does a tool expect a flat tree? public-hoist-pattern hoist only the named packages to the root yes no node-linker=hoisted flat layout for the whole repo; loses strictness no
Declare it if you own the code; extend the manifest if a dependency forgot it; hoist only as a last resort.
  1. 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.

  1. 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": "*"
  1. 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*
  1. Avoid shamefully-hoist=true and node-linker=hoisted except 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.

Phantom imports found during an example migration Counts of phantom imports by source in an example ten-package workspace migrated from npm to pnpm. app and library source 23 test files and fixtures 11 build and lint config 6 third-party packages 3
In a typical migration most phantom imports are in application code, which is where declaring them is straightforward.

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=true without a written reason.
  • Enforce declarations in lint. import/no-extraneous-dependencies in CI stops new phantom imports at review time.
  • Run Knip in CI with --include unlisted as a blocking check.
  • Install from scratch in CI with pnpm install --frozen-lockfile, never reusing a cached node_modules directory — 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