Skip to main content

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.json at 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-strategy is hoisted: shared dependencies are placed in the root node_modules and deduplicated. Alternatives are nested (install in place), shallow (only direct dependencies at the top level), and the experimental linked. Note that the install-links setting 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:

package.json
{
"name": "my-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": ["packages/*"]
}
tip

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 b runs the script in both workspaces.
  • Passing a parent directory selects all workspaces inside it: npm run test -w packages runs test in every workspace under packages/.
  • Running a command from inside a workspace directory implies -w for 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
caution

--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.

note

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:

packages/a/package.json
{
"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.

caution

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:

Capabilitynpmpnpm
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โŒโœ…
note

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 โ€” --workspaces does not run in topological order; order the workspaces array 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โ€‹