I moved a Turborepo monorepo with 14 packages from npm to pnpm. The migration took a day and a half. Three things broke. Two things improved more than I expected. Here is what happened.
The monorepo
The project is a calculator hub built with Astro 5, React, shadcn/ui, and Tailwind CSS 4. The monorepo has 14 packages:
- 2 apps (the main site and a documentation site)
- 4 shared UI packages (components, theme, icons, utils)
- 3 tooling packages (eslint config, tsconfig, tailwind config)
- 5 feature packages (individual calculator modules)
Total dependencies: 847 unique packages across all workspaces. With npm, the node_modules directory was 2.3 GB. Install time on a clean cache: 4 minutes 12 seconds.
What broke
1. Hoisting differences broke two packages.
npm hoists all dependencies to the root node_modules by default. pnpm uses a symlinked structure where each package gets its own node_modules with symlinks to a shared store. This means packages can only access dependencies they explicitly declare in their package.json.
Two of my feature packages were importing a utility (clsx) that was not in their package.json. It worked under npm because clsx was hoisted to the root and accessible from anywhere. Under pnpm, the import failed with "Cannot find module 'clsx'".
The fix was to add clsx to the dependencies of both packages. This is the correct behavior: packages should declare what they import. But if you have been relying on npm's hoisting, pnpm will surface every undeclared dependency in your monorepo.
2. The Turborepo cache needed a reset.
After switching package managers, the Turborepo build cache was stale. Builds were passing but using cached outputs from the npm era, which meant the cache hits were masking potential failures. I had to clear the cache with turbo clean and run a full rebuild to get accurate results.
This is not a pnpm bug. It is a consequence of changing anything that affects the build inputs. But it is easy to miss if you assume the cache is still valid after the migration.
3. One postinstall script failed.
A package had a postinstall script that referenced ../../node_modules/.bin/something. Under npm, this path existed because of hoisting. Under pnpm, the binary is in the package's own node_modules/.bin, not the root. The script failed.
The fix was to use npx something or pnpm exec something instead of hardcoding the path. This is a best practice anyway, but legacy scripts that worked under npm's hoisting model needed updating.
What improved
1. Disk usage dropped from 2.3 GB to 410 MB.
pnpm's content-addressable store deduplicates packages across all workspaces. A package that appears in 10 workspaces is stored once on disk and symlinked. The 2.3 GB node_modules became 410 MB. On a CI runner with limited disk space, this alone justified the migration.
2. Install time dropped from 4:12 to 1:38.
On a clean cache, pnpm install took 1 minute 38 seconds versus npm's 4 minutes 12 seconds. On a warm cache (with the store already populated), pnpm install took 22 seconds. The speedup comes from the hard-linking strategy: packages that are already in the global store are linked, not downloaded.
In CI, where each run starts with a clean checkout but the pnpm store can be cached, the warm-cache install time is what matters. Caching ~/.local/share/pnpm/store in GitHub Actions reduced install time from 1:38 to 28 seconds.
3. pnpm why is better than npm ls.
Tracing why a dependency is installed is clearer with pnpm. pnpm why clsx shows the full dependency chain from root to the package that depends on it. npm ls clsx gives a tree that is harder to read in a monorepo with 14 packages.
The migration steps
If you are doing the same migration, here is what worked:
# 1. Delete npm artifacts
rm -rf node_modules **/node_modules package-lock.json **/package-lock.json
# 2. Enable pnpm
corepack enable
corepack prepare pnpm@latest --activate
# 3. Install
pnpm install
# 4. Fix undeclared dependencies (pnpm will tell you which imports fail)
# 5. Clear Turborepo cache
npx turbo clean
# 6. Full rebuild
pnpm run build
Step 4 is where most of the time goes. pnpm will fail on every undeclared import. Each failure is a package you need to add to a package.json that should have declared it in the first place.
What I would do differently
Run pnpm install --shamefully-hoist first to get a baseline. The --shamefully-hoist flag mimics npm's hoisting behavior, so the install will succeed without fixing undeclared dependencies. This gives you a working baseline. Then remove the flag and fix the failures one by one, testing after each fix.
Check postinstall scripts before migrating. Any script that references ../../node_modules paths will break. Find them with grep -r "node_modules" --include="*.js" packages/ and fix them before switching.
Cache the pnpm store in CI from day one. The install time difference between cached and uncached is 28 seconds vs 1:38. Over a week of CI runs, that is hours saved.
Is it worth it?
For a monorepo with 14 packages, yes. The disk space and install time improvements are immediate and significant. The migration cost is a day and a half of fixing undeclared dependencies, which is work that should have been done anyway.
For a single-package project, the benefit is smaller. The install time and disk improvements are proportional to the number of packages and the amount of dependency overlap. A single app with no shared dependencies will see a smaller speedup.
The undeclared dependency issue is the main friction. If your monorepo has been relying on npm's hoisting for years, pnpm will surface every place where a package imports something it does not declare. That is painful in the short term and correct in the long term.
Top comments (2)
Really honest and practical breakdown of migrating a real monorepo.
The phantom dependencies issue is always the biggest slap in the face when leaving npm's flat
node_modules. While it feels painful on day one, having pnpm strictly enforce declared boundaries makes the codebase so much more resilient long-term.That tip to start with
--shamefully-hoistjust to establish a working baseline before tackling undeclared imports one by one is gold. Also, hardcoded../../node_modules/.binpaths in legacy scripts are such an easy thing to overlook. Swapping those topnpm execis definitely the way to go. Great write-up!Thanks John. The phantom dependencies point is spot on. In my case it was 2 out of 14 packages, but in repos that have been on npm for years the percentage can be much higher. The trick I found with --shamefully-hoist is using it as an audit tool: once you remove the flag, every import failure is an undeclared dependency, and pnpm tells you the exact file and import. On the postinstall paths, grepping for them before migrating saves half a day of debugging. Good to hear your experience lines up.