npm Workspaces
npm Workspaces is npm's native monorepo feature: one root package that manages multiple nested packages in a single repository, with a single install step and a single lockfile. It has been supported since npm v7, so every modern Node.js installation has it out of the box.
This page is a deep dive into how workspaces work, how to set them up, and where they fall short. For related topics, see:
- Publishing from a Monorepo โ versioning and releasing workspace packages
- npm Scripts โ general script patterns and conventions
- npm โ why npm is our default package manager and when to consider alternatives
How workspaces workโ
When you run npm install in a workspaces-enabled project, npm symlinks each workspace into the root node_modules directory under its package name. Because Node.js resolves modules by walking up the directory tree and looking into node_modules, any code in the repository can import a workspace by its package name โ exactly as if it were installed from the registry:
// Works from anywhere in the repo, because node_modules/a -> packages/a
const a = require('a');
This is the automated replacement for the old manual npm link dance. Before workspaces, linking local packages together meant running npm link in one directory and npm link <name> in another, and repeating that for every package and every fresh clone. With workspaces, a single npm install at the root wires everything up.
Two more things happen at install time:
- One lockfile. There is a single
package-lock.jsonat the root that covers every workspace. Individual workspaces do not get their own lockfiles, so the whole dependency tree is resolved and locked in one place. - Hoisted layout by default. The default
install-strategyishoisted: shared dependencies are placed in the rootnode_modulesand deduplicated. Alternatives arenested(install in place),shallow(only direct dependencies at the top level), and the experimentallinked. Note that theinstall-linkssetting has no effect on workspaces โ workspaces are always symlinked, never copied.
Setting up workspacesโ
Declare workspaces in the root package.json with the workspaces field. Entries can be explicit paths or globs:
{
"name": "my-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": ["packages/*"]
}
Always mark the root package as "private": true. The root manifest exists only to coordinate the workspaces โ marking it private prevents you from accidentally publishing it to the registry.
To scaffold a new workspace, use npm init with the -w flag. This creates the folder, generates its package.json, and updates the root workspaces field if needed:
npm init -w ./packages/a
After running npm install, the resulting layout looks like this:
.
โโโ package.json
โโโ package-lock.json
โโโ node_modules/
โ โโโ a -> ../packages/a (symlink)
โโโ packages/
โโโ a/
โโโ package.json
Running commands in workspacesโ
Most npm commands accept the --workspace (-w) flag to target a specific workspace. For example, to add a dependency to workspace a only:
npm install abbrev -w a
The same flag works for running scripts:
npm run test -w a
A few useful behaviors of -w:
- It is repeatable:
npm run test -w a -w bruns the script in both workspaces. - Passing a parent directory selects all workspaces inside it:
npm run test -w packagesrunstestin every workspace underpackages/. - Running a command from inside a workspace directory implies
-wfor that workspace โ npm detects you are in a workspace and scopes the command accordingly.
To run a script across all workspaces, use --workspaces (shorthand -ws). Combine it with --if-present to skip workspaces that don't define the script:
npm run test --workspaces
# skip workspaces without a "test" script
npm run test -ws --if-present
--workspaces runs scripts in declaration order โ the order packages appear in the workspaces array โ not in topological dependency order. If workspace b depends on the build output of workspace a, list a first in the workspaces array, or orchestrate the order yourself. npm will not figure it out for you.
The root package is excluded from --workspaces runs by default. Use --include-workspace-root (shorthand --iwr) to include it.
For general guidance on organizing scripts (naming conventions, pre/post hooks, composition), see npm Scripts.
Cross-workspace dependenciesโ
To make one workspace depend on another, install it by name with -w:
npm install b -w a
npm detects that b is a local workspace, symlinks it into place, and saves a standard semver range in a's manifest:
{
"name": "a",
"version": "1.0.0",
"dependencies": {
"b": "^1.0.0"
}
}
Note what is not there: a workspace: protocol. npm does not have one. The dependency entry is indistinguishable from a registry dependency โ the local link only happens because the name and version match.
The semver-match gotchaโ
Linking is resolved by name plus semver match. npm links the local workspace only if its version field satisfies the declared range. If it doesn't โ say b is at 2.0.0-beta.0 locally but a declares "b": "^1.0.0" โ npm silently fetches b from the registry instead of linking the local copy.
This is the most important thing to know about npm workspaces. A version bump in one workspace can silently break local linking: your code keeps building, but against a published version of the package rather than your local changes. There is no warning. If local changes mysteriously don't take effect, check that every cross-workspace semver range still matches the local versions. This failure mode is exactly what pnpm's workspace: protocol eliminates โ workspace: ranges can only resolve locally.
The flip side affects publishing: because the dependency is a plain semver range, it is published as-is. There is no publish-time rewriting like pnpm performs for workspace: ranges. The range you commit is the range your consumers get, so keep it accurate. See Publishing from a Monorepo for coordinating versions and releases across packages.
Limitations and comparison with pnpm/Yarnโ
npm workspaces cover the basics well, but several features found in other tools are missing:
| Capability | npm | pnpm |
|---|---|---|
| Local workspace linking | โ (by semver match) | โ
(workspace: protocol, always local) |
workspace: protocol | โ | โ (also Yarn Berry) |
| Publish-time range rewriting | โ (ranges published as-is) | โ
(rewrites workspace: ranges) |
| Strict dependency isolation | โ (flat hoisting โ phantom dependencies) | โ
(non-flat node_modules) |
| Topological script ordering | โ (declaration order) | โ
(-r is topological) |
| Task caching / affected-only runs | โ | โ (Turborepo/Nx territory) |
| Dependency catalogs | โ | โ |
npm does not support the workspace: protocol. If you see "dep": "workspace:^" in a manifest, that project uses pnpm or Yarn Berry โ npm cannot install it.
The flat hoisted layout also means phantom dependencies: any workspace can import any hoisted package, even ones it never declared. This works until a dependency tree shuffle removes the package from the root node_modules, at which point imports break in surprising places.
Decision guidance:
- npm workspaces โ a handful of packages with simple build pipelines. Zero extra tooling, works everywhere.
- pnpm โ when you need install speed, disk efficiency, strict dependency isolation, or reliable local linking via
workspace:. - Turborepo / Nx (on top of either) โ when you need task graphs, caching, and affected-only runs in larger monorepos.
Common pitfallsโ
A quick recap of the traps covered above:
- Declaration-order runs โ
--workspacesdoes not run in topological order; order theworkspacesarray deliberately. - Phantom dependencies โ hoisting lets workspaces import undeclared packages; declare everything you import.
- Root excluded from
-wsโ root scripts are skipped unless you pass--include-workspace-root. - Semver-mismatch registry fallback โ a local version that doesn't satisfy the declared range is silently replaced by a registry download.
- Forgetting
"private": trueโ an unprotected root manifest can be published by accident.
Resourcesโ
- npm Workspaces documentation
- package.json reference โ
workspacesfield - npm config reference โ
install-strategy,include-workspace-root - pnpm Workspaces โ for comparison with the
workspace:protocol