Node.js, part 13: npm workspaces — a monorepo without adding a tool
Part 13from the Node.js series · 24 parts in all
Splitting a codebase into packages usually comes with a decision: adopt a monorepo tool
(Lerna, Nx, Turborepo) or duplicate everything. npm 7 — bundled with Node 16 — added
workspaces, which covers the common case of "a few packages and an app that
depends on them" with no extra dependency at all. Part 13 is that, and where it stops being
enough.
The whole setup
{
"name": "maplecart",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
maplecart/
package.json
packages/
pricing/package.json name: @maplecart/pricing
db/package.json name: @maplecart/db
apps/
api/package.json depends on @maplecart/pricing
One npm install at the root installs every workspace and hoists shared
dependencies into the root node_modules. Crucially, a local package is linked by
name — apps/api imports @maplecart/pricing and gets the working
copy, not a published version, so an edit in one package is live in the other with no build
step or npm link ceremony.
The commands
npm install # all workspaces, one lockfile at the root
npm test --workspaces # run the script in every workspace
npm run build --workspace apps/api
npm install lodash --workspace packages/pricing
npm ls --all # who depends on what, across the tree
That last command is the one that pays for itself: with hoisting, "which package pulled this in?" is no longer guessable from the folder you are standing in.
Where it stops being enough
- No build graph.
--workspacesruns everything, in order, every time. Incremental and affected-only builds are what Turborepo and Nx add. - Hoisting is a lie you should know about. A workspace can import a package it never declared, because a sibling happened to install it. It works locally and breaks on publish — a lint rule or a CI check that installs each workspace alone catches it.
- One lockfile means one resolution. Two packages needing incompatible
majors of the same library get a nested
node_modules, which is correct and occasionally surprising.
For two to ten internal packages, workspaces is the right answer and a framework would be overhead. Next: measuring what actually runs, with the profiler Node already has.