npm Workspaces: Built-in Monorepo Support Without the Tooling Tax
npm 7+ includes built-in monorepo support via workspaces — no extra tools, single lockfile, symlinked local packages. Here's how it works, a minimal reproducible example, and the gotchas (phantom deps, no version sync) to watch before you publish.
28 Jul 2025, 12:00 UTC

The problem: multiple packages, one repository, too much ceremony
You have a repository with a shared UI library, a backend API package, and a CLI tool. Each has its own package.json. You want them to share dependencies, import each other locally without publishing, and run tests across all of them in one command. Historically this meant npm link, file: dependencies, or adopting Lerna, Yarn Workspaces, or pnpm. Since npm 7 (October 2020), the same capability ships in the CLI you already use.
What npm workspaces actually does
Add a workspaces field to the root package.json pointing at a glob pattern — commonly "packages/*". When you run npm install from the root, npm:
- Creates a single
package-lock.jsonfor the entire tree - Hoists shared dependencies to the root
node_modules - Symlinks each workspace package into every other workspace's
node_modulesunder its declared name
No extra binary, no separate lockfile format, no bootstrap step. The workspace packages are regular npm packages that happen to live side by side.
Worked example: two packages that depend on each other
Create a minimal repo to see the mechanics:
mkdir ws-demo && cd ws-demo
npm init -y
# Edit package.json to add:
# "workspaces": ["packages/*"]
mkdir -p packages/a packages/b
cat > packages/a/package.json <<'EOF'
{
"name": "@scope/a",
"version": "1.0.0",
"main": "index.js"
}
EOF
cat > packages/b/package.json <<'EOF'
{
"name": "@scope/b",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@scope/a": "^1.0.0"
}
}
EOF
echo "module.exports = { greet: () => 'from A' };" > packages/a/index.js
echo "const a = require('@scope/a');
console.log(a.greet());" > packages/b/index.js
npm install
After install, packages/b/node_modules/@scope/a is a symlink to ../../a. Run node packages/b/index.js and it prints "from A". No npm link, no file: specifiers, no postinstall scripts.
Running scripts across the workspace
Add a build script to packages/a/package.json only:
"scripts": { "build": "echo building A" }
Then from the root:
npm run build --workspaces --if-present
npm executes the script in topological order (dependencies first) and --if-present skips packages that lack the script. npm test --workspaces, npm run lint --workspaces — same pattern. In CI, npm ci reads the root lockfile and installs the entire workspace in one deterministic pass.
Publishing: per-package, not all-at-once
npm publish from packages/a publishes only @scope/a. Since npm 9, npm publish --workspaces can publish multiple changed packages, but version bumps still need coordination. npm version --workspaces patch bumps every workspace package; pair it with a changelog tool (changesets, release-it) if you want synchronized releases with per-package notes.
Where it bites: phantom dependencies and missing version sync
Hoisting is convenient but dangerous. If packages/b require()s lodash without declaring it, the hoisted copy in the root node_modules satisfies it locally. When you publish @scope/b, downstream consumers get a broken package. Catch this before publish:
cd packages/b && npm pack --dry-run
Inspect the generated tarball's package.json — missing dependencies will be obvious. Tools like publint automate the check.
Other limitations:
- Cyclic workspace dependencies are forbidden; refactor shared code into a third workspace package.
- Native addons (
node-gyp) may rebuild per workspace; pin toolchain versions viaenginesor.nvmrc. - IDE autocomplete for workspace imports often needs
jsconfig.jsonpathsor TypeScript project references — not automatic.
When to reach for something else
npm workspaces covers the common case: a handful of related packages, shared dependencies, local development without publishing. Consider pnpm or Turborepo when you need stricter isolation (pnpm's non-hoisted node_modules prevents phantom deps by design), remote caching, or task orchestration across large graphs. But for many teams, the built-in feature removes a whole category of tooling decisions — run npm init -y, add the workspaces field, and start writing code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.