npm Workspaces for Small Monorepos: Hoisting, Phantom Dependencies, and Lockfile Control
npm workspaces replace npm link and file: protocols with symlinks and a single lockfile, but hoisting can hide undeclared dependencies. Here is how to set them up, verify linking, and catch phantom imports before they reach CI.
27 Feb 2026, 05:28 UTC

The problem: local packages that do not break in CI
You maintain a shared utility package and an application that consumes it. You want edits in the utility package to appear in the application immediately, without publishing to a registry. The usual shortcuts—npm link or file: dependencies—work on your laptop and then fail in continuous integration because symlinks are not preserved or lockfiles resolve differently. npm workspaces, available since npm 7, solve this by treating the repository as a single install unit.
What workspaces actually do
Declare a workspaces field in your root package.json. When you run npm install at the repository root, npm reads every matching package, creates a symlink in the root node_modules for each local package, and hoists shared dependencies to that same root directory. The result is one dependency tree, one lockfile, and instant local linking.
This is different from npm link, which creates global symlinks that CI environments often ignore, and different from file: dependencies, which can copy rather than link depending on the tooling. Workspaces keep everything inside the project.
A minimal two-package setup
Assume a repository with this structure:
my-monorepo/
├── package.json
└── packages/
├── utils/
│ └── package.json
└── app/
└── package.json
The root package.json declares the workspace glob:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*"
]
}
The utility package declares its own dependencies:
{
"name": "@acme/utils",
"version": "1.0.0",
"dependencies": {
"lodash": "^4.17.21"
}
}
The application depends on the utility package by name:
{
"name": "@acme/app",
"version": "1.0.0",
"dependencies": {
"@acme/utils": "1.0.0"
}
}
Run npm install from the repository root. You need write access to the project directory. After the install finishes, verify the link:
ls -la node_modules/@acme/utils
Expected check: the output should show a symlink pointing to ../packages/utils. If it is a real directory rather than a symlink, the workspace was not recognized—confirm your root package.json is valid JSON and that the glob matches the directory names.
To inspect the full workspace tree, run:
npm ls --workspaces
This prints the dependency graph for every declared workspace. It is the fastest way to confirm that @acme/app resolves @acme/utils through the local symlink rather than a registry version.
The phantom dependency trade-off
Hoisting is efficient, but it creates a sharp edge. Because lodash is installed at the root node_modules, the application can import it even though @acme/app never declared it:
const _ = require('lodash');
This is a phantom dependency. It resolves locally and in CI as long as some other workspace keeps lodash at the root. The day that dependency is removed, renamed, or nested because of a version conflict, the application breaks with a module-not-found error.
You can detect this before it becomes an incident. From the repository root, ask npm to report the dependency as the application sees it:
npm ls lodash --workspace @acme/app
If lodash is a declared dependency, npm prints the resolved version. If it is phantom, npm reports it as invalid or missing depending on your npm version. Treat any undeclared import as a bug and add it to the correct package.json.
Hoisting also does not guarantee a flat tree. If two workspaces require incompatible versions of the same library, npm nests one version inside the dependent package's node_modules. Runtime behavior can therefore differ from a purely flat layout, and assumptions about a single shared version are unsafe.
One lockfile, deterministic installs
Workspaces collapse every package into a single root package-lock.json. Commit that file. In CI, use npm ci instead of npm install. The ci command installs strictly from the lockfile and fails if the lockfile and package.json disagree, which prevents version drift between developers and automation.
Migration is the riskiest step. If your packages previously had their own lockfiles, remove or reconcile them before the first root install. Leftover per-package lockfiles can cause npm to resolve different trees than expected. Also verify your npm version before scripting workspace flags: filtering syntax and available options have changed across npm releases, so run npm install --help inside your CI image and confirm the flags you rely on exist there.
When to choose workspaces
Pick workspaces when you have two or more local packages that change together and you want a single install command. Skip them if your packages are truly independent, released on separate schedules, and consumed by external teams who do not need your source tree.
Start small. Create a two-package repository, run npm install, confirm the symlink, commit the root lockfile, and run npm ls for every external import. If that workflow feels stable, add the remaining packages one at a time. The payoff is a monorepo that installs in one step and links locally without registry round-trips—provided you keep phantom dependencies out of the codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.