My Pedalboard monorepo has been running on Yarn 3 workspaces for quite a while now. I've written about it many times, setting it up, refactoring workspace scripts, making it build just what's needed, and even going through a whole Lerna drama. It's been a journey, and Yarn has served me well.
But here's the thing, the JavaScript ecosystem moves fast, and pnpm has been gaining serious traction. Its strict dependency handling, blazing speed, and disk-space efficiency are hard to ignore. So I figured it's time to rip off the band-aid and migrate my workspaces from Yarn to pnpm.
In this post, I'll walk you through the actual migration process, the gotchas I ran into, and whether pnpm lives up to the hype for a real-world monorepo.
The Current Setup
Before we start tearing things apart, let's look at what we're working with. The Pedalboard monorepo currently runs on:
- Yarn 3.2.0 with the workspace-tools plugin
- Lerna (independent versioning) for publishing
-
7 packages under
packages/*- React components, hooks, linting plugins, and dev tools
Here's the root package.json workspace configuration:
{
"workspaces": [
"packages/*"
],
"packageManager": "yarn@3.2.0"
}
And the .yarnrc.yml that ties it all together:
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.2.0.cjs
The workspace scripts use Yarn's workspaces foreach command for running tasks across packages:
{
"test": "yarn workspaces foreach -pRv run test",
"lint": "yarn workspaces foreach -pRv run lint",
"build": "yarn workspaces foreach -ptv run build"
}
So there's Yarn-specific syntax sprinkled throughout. Let's see what it takes to swap it out.
Out With the Old
The first step is satisfyingly destructive, deleting all the Yarn-specific files:
-
.yarnrc.yml- gone -
.yarn/directory (vendored binary, plugins, cache) - gone -
yarn.lock- gone
In their place, pnpm needs just one config file. I created a pnpm-workspace.yaml at the root:
packages:
- 'packages/*'
That's it. No plugins, no vendored binary, no special config. Already feeling lighter.
Translating the Scripts
This is where the meat of the migration lives. Yarn and pnpm have different CLI syntax for workspace operations, so every script that touches workspaces needs updating.
The root package.json scripts went from this:
{
"test": "yarn workspaces foreach -pRv run test",
"test:since": "yarn workspaces foreach --since -pRv run test",
"lint": "yarn workspaces foreach -pRv run lint",
"build": "yarn workspaces foreach -ptv run build"
}
To this:
{
"test": "pnpm -r run test",
"test:since": "pnpm --filter '...[origin/master]' run test",
"lint": "pnpm -r run lint",
"build": "pnpm -r run build"
}
The pnpm -r flag handles recursive execution across packages. But the interesting bit is the --since replacement.
How pnpm Handles "Since"
Yarn's --since flag runs commands only on packages that changed compared to the default branch. pnpm achieves this with --filter using a git-diff syntax:
pnpm --filter '...[origin/master]' run test
The [origin/master] part selects packages where files changed compared to origin/master. The ... prefix is the clever bit - it means "also include dependents of changed packages." So if @pedalboard/hooks changed, @pedalboard/components (which depends on it) gets selected too. That's exactly the behavior we want in CI.
I also had to update yarn bundle references in individual package scripts to pnpm run bundle, and swap --untraced yarn.lock to --untraced pnpm-lock.yaml in the Chromatic commands.
One subtlety: the coverage script passes extra flags to the test scripts recursively. With Yarn the syntax was:
"coverage:all": "yarn workspaces foreach -pRv run test -- --coverage --silent"
The natural pnpm equivalent would be:
"coverage:all": "pnpm -r run test -- --coverage --silent"
But pnpm v9 changed how -- is handled - it now passes the -- separator through to the underlying script, so jest ends up receiving "--" "--coverage" "--silent" and treats -- as a test name pattern instead of a separator. The fix: drop the extra -- and pass the flags directly:
"coverage:all": "pnpm -r run test --coverage --silent"
pnpm passes unrecognized flags through to the script, so --coverage reaches jest correctly.
Updating Lerna
Lerna's config needed two changes. The npmClient field changed from "npm" to "pnpm", and I removed the now-obsolete bootstrap command block:
{
"npmClient": "pnpm",
"command": {
"publish": {
"ignoreChanges": ["ignored-file", "*.md"],
"message": "chore(release): publish %s"
},
"version": {
"message": "chore(release): version",
"allowBranch": "master",
"conventionalCommits": true
}
},
"packages": ["packages/*"],
"version": "independent"
}
Another Yarn holdover was the resolutions field in the root package.json. pnpm uses its own format for dependency overrides:
{
"pnpm": {
"overrides": {
"parse-url": "^8.1.0"
}
}
}
The pnpm Strictness Tax
Here's where things got interesting. pnpm's strict dependency isolation is one of its biggest selling points - it prevents phantom dependencies (using packages you haven't explicitly declared). But it also means that things that "just worked" under Yarn's hoisting can break.
I hit six cases of this:
1. Missing @types/react in the hooks package
error TS7016: Could not find a declaration file for module 'react'.
My @pedalboard/hooks package uses React (it's a hooks library, after all) but only declared react as a peerDependency - no @types/react in devDependencies. Under Yarn, the types were hoisted from @pedalboard/components and available everywhere. Under pnpm, each package only sees its own declared dependencies.
Fix: Added @types/react to the hooks package's devDependencies. Honestly, this should have been there all along - pnpm just caught the sloppy dependency declaration.
2. Shared esbuild config couldn't find its dependencies
Error: Cannot find module 'esbuild-sass-plugin'
The esbuild.config.js lives at the monorepo root and is called from package directories via node ../../esbuild.config.js. It requires esbuild and esbuild-sass-plugin, but those were only declared in packages/components. Node resolves require() relative to the file's location (the root), not the CWD. Under Yarn, hoisting put them in the root node_modules. Under pnpm, they weren't there.
Fix: Added esbuild and esbuild-sass-plugin to the root devDependencies. Since the config file lives at the root, its dependencies should be declared there.
3. The postcss module in the stylelint plugin
Same story - @pedalboard/stylelint-plugin-craftsmanlint imports postcss types but only had stylelint (which depends on postcss) as a direct dependency. pnpm doesn't allow accessing transitive dependencies.
Fix: Added postcss as an explicit devDependency.
4. The workspace: Protocol for Inter-Package Dependencies
This was the most subtle one. The @pedalboard/components package depends on @pedalboard/hooks with a regular version range:
{
"dependencies": {
"@pedalboard/hooks": "^0.3.1"
}
}
Under Yarn, workspace symlinks meant Jest could transform and test this dependency just fine. Under pnpm, the module resolution goes through .pnpm/ store paths, which tripped up Jest's transformIgnorePatterns - Jest refused to transform the ESM export statements in the hooks package.
The proper pnpm fix is the workspace: protocol:
{
"dependencies": {
"@pedalboard/hooks": "workspace:^"
}
}
This tells pnpm to always link to the local workspace package. During pnpm publish, it automatically replaces workspace:^ with the actual version range like ^0.3.1. Clean, explicit.
I also updated every other inter-workspace dependency to use workspace:^. For example, @pedalboard/components also depends on @pedalboard/stylelint-plugin-craftsmanlint as a devDependency, which got the same treatment.
One thing to watch out for: any time you change a workspace:^ specifier, you need to run pnpm install locally and commit the updated pnpm-lock.yaml. CI runs with --frozen-lockfile by default, so it will fail hard if the lockfile doesn't match package.json. I learned this the fun way when the CI pipeline blew up with:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with packages/components/package.json
Just run pnpm install and commit the lockfile. Simple fix, easy to forget.
But that alone wasn't enough. Jest was still choking on the @pedalboard/hooks ESM code. The root cause: pnpm's module resolution routes workspace packages through its .pnpm/ store paths, and Jest's default transformIgnorePatterns excludes everything in node_modules - including those store paths. The fix was a one-liner in jest.config.base.js:
transformIgnorePatterns: ['node_modules/(?!@pedalboard)'],
This tells Jest: "ignore node_modules for transformation, except for anything under @pedalboard." With that, Jest correctly transforms the workspace packages' TypeScript/ESM source. It's a small change with an outsized impact.
5. Storybook's @storybook/node-logger version conflict
This one only showed up when running Chromatic. The Storybook build failed with:
Error: Cannot find module 'storybook/internal/node-logger'
storybook/internal/node-logger is a v8+ internal path that doesn't exist in Storybook v7. But the project uses v7. What happened: pnpm's resolver pulled in @storybook/node-logger@8.6.14 as a transitive dependency from somewhere, and its shim tries to import from the v8 internal path. Under Yarn, the v7 version was consistently hoisted everywhere.
Fix: pin it in pnpm's overrides:
{
"pnpm": {
"overrides": {
"@storybook/node-logger": "7.6.21"
}
}
}
6. Missing style-loader and css-loader in components
After fixing the logger, Storybook's build still failed:
Module not found: Error: Can't resolve 'style-loader'
The Storybook config in packages/components/.storybook/main.js uses @storybook/addon-styling-webpack with explicit webpack rules that reference style-loader and css-loader. Under Yarn, these were hoisted from some transitive dependency and available globally. Under pnpm, they need to be declared where they're actually used.
Fix: added style-loader and css-loader to @pedalboard/components's devDependencies.
CI/CD Updates
The three GitHub Actions workflows needed the pnpm/action-setup@v4 step added before Node setup, and all yarn commands replaced with pnpm equivalents:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: pnpm install
- run: pnpm run build
The pnpm/action-setup@v4 action reads the packageManager field from package.json to know which pnpm version to install. No extra configuration needed.
I also updated a hardcoded spawn('yarn', ...) call in the coverage aggregation script to use pnpm.
Bonus: TypeScript Gaps Yarn Was Hiding
This one isn't strictly a pnpm migration issue, but pnpm's strictness smoked it out. When building @pedalboard/components, I got:
error TS2550: Property 'fill' does not exist on type 'any[]'.
Do you need to change your target library? Try changing the 'lib' compiler option to 'es2015' or later.
Array.fill is ES2015. We were using it. TypeScript didn't know about it because tsconfig.base.json had no target or lib set - which defaults to ES3. Yarn's hoisting had been accidentally providing a newer TypeScript version from one of the workspace packages, masking the gap.
The fix: add explicit target and lib to the base config:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"]
}
}
And since only the React packages (components, hooks, media-loader) run in a browser, DOM types belong there specifically - not in the base that's shared with Node.js tooling packages like the ESLint and Stylelint plugins:
{
"compilerOptions": {
"lib": ["ES2020", "DOM"],
"module": "ES2020"
}
}
A latent bug that was hiding in plain sight. pnpm didn't cause it - it just made it impossible to ignore.
Lerna Needed More Convincing
After all that, I thought the migration was done. Then I tried to run lerna publish and got:
lerna ERR! ENOWORKSPACES Usage of pnpm without workspaces is not supported.
The root cause: Lerna v5 has zero knowledge of the workspace:^ protocol. When it publishes @pedalboard/components, it sends the package.json as-is, workspace:^ and all. npm receives "@pedalboard/hooks": "workspace:^" as a literal dependency version, tries to resolve it in the registry, finds nothing, and returns a deeply unhelpful 404.
The real fix: upgrade Lerna. Support for the workspace: protocol was added in v6.1.0, and in v8 (current latest) it's solid. Bump the version:
{
"devDependencies": {
"lerna": "^8.0.0"
}
}
In lerna v8, workspace discovery from pnpm-workspace.yaml happens automatically - the old useWorkspaces option was removed (it'll throw ECONFIGWORKSPACES if you leave it in). Lerna v7+ also pulls in nx as a task runner by default, which I don't need:
{
"npmClient": "pnpm",
"useNx": false
}
Registry auth with pnpm publish
With lerna v5, npmClient: pnpm only affected installs - lerna always called npm publish internally regardless. The auth setup that actions/setup-node generates worked fine because npm expands the ${NODE_AUTH_TOKEN} variable from its generated .npmrc.
Lerna v8 actually honors npmClient for publishing too, so now pnpm publish is doing the work. pnpm doesn't pick up ${NODE_AUTH_TOKEN} from the npm-generated config the same way. The fix: a .npmrc at the project root that pnpm always reads:
registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
Safe to commit - no real token, just an env var reference that resolves correctly in CI.
Wrapping Up
The migration is done and everything is green - all 7 packages build, all tests pass, and linting works (the few lint errors that remain are pre-existing, not migration-related).
The biggest takeaway? pnpm's strict dependency isolation is a feature, not a bug. It caught several sloppy dependency declarations that Yarn's hoisting was silently papering over. Every package now explicitly declares what it uses, and that's just better engineering.
The workspace: protocol is a nice touch too - it makes inter-package dependencies explicit and handles the version replacement during publishing automatically. I wish I'd had this from the start.
Was it worth the migration effort? yes, but there is still more to come which will explain why I chose to migrate to pnpm ;)
Top comments (0)