Setting Up a Monorepo with pnpm Workspaces
The trial and error of setting up a pnpm workspace monorepo from scratch. The official docs weren't enough.
Why I switched from npm to pnpm
node_modules eating 1.2GB per project was driving me insane. Three projects open at once meant 3.6GB devoted to node_modules on disk. pnpm stores packages once in a global store and uses symlinks. Once I learned this, I switched immediately.
Install speed improved too. 87 seconds with npm, 23 seconds with pnpm. Makes sense -- if a package is already in the global store, there's nothing to download.
Setting up pnpm workspaces
Create one pnpm-workspace.yaml file and you're done. At least, I thought it was that simple.
I set up 3 packages: packages/web (frontend), packages/api (backend), packages/shared (shared utils). The workspace config itself took 5 minutes. Then the real problems began.
TypeScript path configuration hell
To import the shared package from web, TypeScript needs to know the path. You have to configure tsconfig paths, and this has to be done per package. You can do it at the root tsconfig or in each package -- either way, nothing worked on the first try.
After an hour of fumbling, I figured out that the shared package's package.json needs proper main and types fields. The official docs are unhelpful on this point.
(Had 4 Stack Overflow tabs open, comparing answers.)
Script management
Running pnpm -r run build from the root builds all packages. Order matters -- shared needs to build before web and api. pnpm resolves this automatically based on dependencies, but I hadn't created a build script for shared initially, so dependency resolution broke.
The pnpm --filter web dev filter feature is nice. You can target specific packages for script execution. But memorizing the filter syntax took some time. --filter web... includes web and all its dependencies. --filter web is just web alone. Three dots make a big difference.
Version management dilemma
How to manage package versions in a monorepo was a real question. Several options: changeset, unified versioning, independent versioning.
Ours was internal with no npm publishing, so we didn't bother with versioning. That bit us later. When shared was updated, web pulled stale build artifacts from cache and the changes didn't reflect. Ended up adding a script to force-rebuild shared on every build. Not pretty.
CI issues
Using pnpm in GitHub Actions requires adding the setup-pnpm action. Thought it'd take 5 minutes, but it failed once because of a pnpm version / Node.js version mismatch. You need pnpm install --frozen-lockfile in CI to install without modifying the lock file -- skip this and every PR gets a messy lock file diff.
Looking back
pnpm workspaces themselves are straightforward. But once TypeScript, build tools, and CI get involved, budget two days for initial setup. Once it's done, it's smooth. But that "once" takes longer than you'd expect.
Next time I set this up, I'll re-read this post and probably finish in half a day. Probably.