Linking Local Packages in an npm Monorepo Without Publishing
npm workspaces let you symlink local packages from a single root install, share one lockfile, and target scripts per sub-package—without publishing to a registry.
16 Jun 2026, 05:32 UTC

Problem: Broken local links and duplicated installs in a multi-package repo
When you split a project into multiple packages—say a shared utility library and a UI component library—each sub-directory usually needs its own node_modules. Without a workspace manager, local packages either get published to a registry or linked manually with npm link, which often breaks when continuous integration runs or when transitive dependencies drift. The practical outcome you want is a single npm install at the repository root that symlinks local packages into one shared node_modules tree.
Prerequisites and version assumptions
Native workspaces require npm 7 or higher. Check with npm --version. The repository must have a root package.json and sub-package directories, each with its own package.json containing a unique name field. Workspaces are not supported in npm 6 or earlier; those versions ignore the workspaces field and install each package independently.
A symlink (symbolic link) is a filesystem pointer that makes one directory appear inside another. In this setup, npm creates symlinks so that node_modules/@acme/utils points directly to your local packages/utils source.
Step 1: Declare workspace paths in the root package.json
Add a workspaces array to the root package.json. Each entry is a glob pattern relative to the root.
{
"name": "acme-monorepo",
"private": true,
"workspaces": [
"packages/*"
]
}
In this example, any directory under packages/ that contains a package.json becomes a workspace. A typical layout looks like packages/utils/package.json and packages/ui/package.json.
Step 2: Name each sub-package
Each sub-package needs a unique name so the root can symlink it. For example, packages/utils/package.json might declare "name": "@acme/utils" and packages/ui/package.json might declare "name": "@acme/ui". If @acme/ui depends on @acme/utils, list it under dependencies using the exact workspace name.
{
"name": "@acme/ui",
"version": "1.0.0",
"dependencies": {
"@acme/utils": "^1.0.0"
}
}
Step 3: Install from the root
Run the following command in the repository root:
npm install
This requires write access to the project directory and network access to the npm registry. npm reads the workspaces field, resolves every local package, and creates symlinks inside the root node_modules. Dependencies that are identical across workspaces are hoisted—moved up to the root node_modules—to reduce disk usage, while conflicting versions remain nested inside the specific workspace that needs them.
Step 4: Verify the link and dependency tree
To confirm local packages are linked, inspect the root node_modules for symlinks:
ls -la node_modules/@acme/
On macOS and Linux, you should see a symlink entry for utils pointing to ../../packages/utils. On Windows, use dir node_modules\@acme in Command Prompt or Get-ChildItem node_modules\@acme in PowerShell.
Next, verify the dependency tree from the root:
npm ls
If the tree is healthy, npm ls completes without missing-package errors and lists linked workspace entries. If a dependency is missing or a version range cannot be satisfied, npm prints an error and exits with a non-zero code.
Step 5: Target a specific workspace
From the repository root, use the -w (or --workspace) flag to run scripts or install packages for one sub-package without changing directories.
npm run build -w @acme/ui
To add a dependency only to the @acme/ui workspace:
npm install lodash -w @acme/ui
This modifies packages/ui/package.json and the root lockfile. To revert that specific change, run npm uninstall lodash -w @acme/ui from the root.
Expected checks and common failure modes
A correct setup produces three observable results: symlinks in root node_modules point to local directories, npm ls resolves without missing-package errors, and npm run <script> -w <name> executes the script defined in that sub-package.
One risk is phantom dependencies. Because npm hoists shared modules to the root, a sub-package can accidentally import a library that exists in root node_modules but is not declared in its own package.json. This works locally but fails when the package is consumed elsewhere. Detect it by auditing each sub-package's imports against its declared dependencies; npm ls can reveal unexpected paths, but manual review is the reliable check.
Another failure mode is circular dependencies between workspaces—for example, @acme/ui depends on @acme/utils while @acme/utils also depends on @acme/ui. npm may fail to resolve the link order during installation. Break the cycle by extracting the shared code into a third workspace or by moving the common types into a separate package that both can depend on.
Recovery options
If symlinks are missing or stale, delete the root node_modules and the root package-lock.json, then run npm install again from the root. This is a state-changing operation: the lockfile will be regenerated, which may update transitive versions within the ranges declared in your package.json files. If you need to preserve exact versions, commit the lockfile to version control before deleting it.
If a workspace was added or removed, re-run npm install at the root so npm can update the symlink graph. Removing a workspace directory without updating the root workspaces glob may leave dangling symlinks until the next install.
Limitations
- Workspaces require npm 7+; older npm versions do not support the
workspacesfield. - Hoisting improves disk usage but can mask missing declarations in sub-packages.
- Circular workspace dependencies can block installation or produce resolution errors.
- The
-wflag must be run from the repository root; running it inside a sub-package directory targets that package implicitly but does not accept a workspace name.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.