I've spent a lot of time with the big-three Node.js package managers (npm, pnpm, and yarn), and I've consistently found pnpm to deliver the best overall experience. Of particular interest to me is that it works great with "monorepos" (many packages in one git repository).
While pnpm's defaults are solid, there are a bunch of tweaks that make things even better. After a lot of trial and error I've found the settings that make my development experience great.
The first sections of this post are for people new to pnpm. For those already familiar, you can jump right to my configuration recommendations.
Differences from npm
pnpm is largely a drop-in replacement for npm: just plop in a package.json
file and start using pnpm <whatever>
commands!
There are some differences that are good to be aware of:
- Monorepos are specified using a
pnpm-workspace.yaml
file instead of the"workspaces"
field inpackage.json
that npm and yarn use. - You can run all of your
package.json
scripts viapnpm <script-name>
(npm lets you do that for some scripts, but others require thenpm run
subcommand) - You can also run your installed binaries with
pnpm <command>
. For example, you might want to run local versions of Typescript (pnpm tsc
) or eslint (pnpm eslint
). npm uses a separate command (npx
) to run binaries. - pnpm separates the installing the dependencies listed in your
package.json
(via the commandpnpm install
) from adding new dependencies (via the commandpnpm add
). npm usesnpm install
for both. -
To add a local dependency within a monorepo, in your
package.json
"dependencies"
field you'll prefix your local dependencies' version-range strings withworkspace:
(a.k.a. the "workspace protocol"). When publishing, pnpm resolves those prefixed versions to their actual versions in the published packages. For example, if I have packagea
depending onb
in my monorepo, thepackage.json
fora
would look like:
"dependencies":{ "b": "workspace:*" // (where '*' means "any version") }
There are many other differences, but the above are the most likely to trip you up when switching from npm.
Installing pnpm
I recommend using corepack since that keeps things nice and automagic, but there are also other ways.
Basic setup for monorepos
There is only one thing you need to do to enable pnpm for your monorepo: add a pnpm-workspace.yaml
file alongside your root package.json
. It should look like this:
# The only field in this config file!
packages:
# Add paths to each package's folder, or glob patterns
- 'packages/*'
That's it! Now you're ready to use pnpm. BUT, with some additional configuration you can get a much slicker dev experience.
Switching from npm
While the switch from npm to pnpm is pretty smooth, there are some changes you'll have to make:
- Nuke all
node_modules
folders withrm -rf **/node_modules
. pnpm stores dependencies completely differently and will need to download everything fresh. - In your
package.json
files, update all local dependency listings to use the workspace protocol. For example,{dependencies:{myLocalPackage:"^1.2.3"}}
would become{dependencies:{myLocalPackage:"workspace:*"}}
- Find all instances of the words "npm" and "npx" across all projects and replace them with
pnpm
. You can use a regex search like\bnp[mx]\b
and restrict the search to appropriate filepaths to do this quickly and with minimal error. -
Update your VSCode
settings.json
:
//... "search.exclude": { // Avoid polluting search results with lockfile content "pnpm-lock.yaml": true, }, // Ensure VSCode uses pnpm instead of npm "eslint.packageManager": "pnpm", "npm.packageManager": "pnpm", // For those using file-nesting, nest the new files. E.g.: "explorer.fileNesting.patterns":{ "package.json": "pnpm-workspace.yaml, pnpm-lock.yaml" }
Get the best developer experience
The pnpm experience is solid from the jump, but you can make it GREAT by digging into the configuration options. The following are my recommendations for how to configure pnpm in a monorepo context:
The .npmrc
file
Just like npm, pnpm reads configuration information from .npmrc
files. These files use key=value
pairs to set config options.
Notably, any configuration settings in your repo-root .npmrc
file will also apply to all of the packages within your monorepo! This is super useful, since it allows you to centralize your pnpm settings. You can even override settings for a specific package by adding a different .npmrc
alongside that package's package.json
.
Create a .npmrc
file alongside your root package.json
file, and then set the following (see the docs for all options):
-
use-node-version=18.16.0
(or whatever version you want). This setting causes pnpm to use the listed Node version for its operations, including when you run yourpackage.json
scripts or run node itself (if you do so viapnpm node
instead of callingnode
directly). pnpm will even install that version for you if it isn't already available! You can also change this setting for any specific package, allowing you to ensure that your packages are always using the node version you want without you ever having to think about it. -
strict-peer-dependencies=false
Peer dependencies are a nightmare. I always set this tofalse
to make them less so. -
publish-branch=main
(or whatever your main git branch is). This preventspnpm publish
from running if you aren't on the specified branch, which can save you some headaches. -
save-prefix=""
. This makes it so that exact versions are used when you runpnpm add
. This is a good default since it's the safest, and you can always change the behavior for specific dependencies by manually editing their version (e.g. by changing"1.1.3"
to"^1.1.3"
). -
link-workspace-packages=false
. Your monorepo packages can depend on each other, but how does pnpm decide if you mean the local version of a dependency (allowing symlinking right to the source) versus a specific past version (requiring downloading separate files)? pnpm provides a few ways to handle this, but setting this option tofalse
is the most predictable and explicit. And that means fewer surprises! This option tells pnpm to only resolve local deps to the local files when theworkspace:
protocol is used, and to otherwise download published versions. -
save-workspace-protocol=rolling
. This dictates howpnpm add
decides whether or not to include theworkspace:
prefix and whether or not the version will be specified. Whatever default you choose, you can easily override a dependency by manually editing its version string in yourpackage.json
dependencies. There's significant nuance in which option to choose for defaults that make sense for your use case:-
false
: Never use theworkspace:
prefix. In combination withlink-workspace-packages=false
newly-added deps will always resolve to published, static artifacts (instead of local ones). This yields the fewest surprises, but doesn't let you take full advantage of having all of your packages in one repo. -
true
: Always include theworkspace:
prefix and the version of the dep at the time ofpnpm add
, for local dependencies. This is the most balanced choice if you have really good versioning and testing tooling, since you can still do rapid development across dependency boundaries but will get alerted when a dep gets bumped too far. -
rolling
: Always include theworkspace:
prefix but not the specific version. This provides the smoothest development experience, since you can freely move between projects and see the consequences on their dependents instantly, without having to deal with semver range conflicts. However, you need robust versioning and testing tooling to stay out of trouble since it's easy to publish something without also publishing its updated dependency, among other issues. This case is best reserved for monorepos where all projects always get version-bumped at the same time, and automatically, if there have been any changes to them.
-
Manage dependency versions with syncpack and depcheck
pnpm helps to reduce downloads and the size of dependencies on disk by symlinking shared dependencies. However, when your shared dependencies use incompatible versions you lose a lot of those benefits. This is a particular problem when you use the more conservative settings recommended above, like forcing exact versions of dependencies instead of ranges.
To mitigate this, I recommend using syncpack. Syncpack looks at all of your package.json
files to find all common dependencies, and ensures they're all at the same version. If you need more nuance than that, it also has a lot of configuration options.
Add syncpack fix-mismatches
to the "postinstall"
script in your package.json
to ensure that your dependency versions are being frequently synced.
Finally, over time you're likely to end up with some stray dependencies that are no longer in use by your packages. depcheck is a great tool for this, and it also has a ton of configuration options.
Top comments (0)