You are currently viewing Managing Monorepo Dependencies with npm Workspaces: A Practical Guide
Photo by Bibek ghosh on Pexels

Managing Monorepo Dependencies with npm Workspaces: A Practical Guide

  • Post category:NPM
  • Post comments:0 Comments
  • Reading time:5 mins read
  • Post last modified:September 9, 2026

As projects grow, it’s common to split a codebase into multiple packages: a shared UI library, a backend API, a set of internal utilities. Historically, managing these as separate npm packages meant either publishing each one to a registry after every change, or reaching for a heavier tool like Lerna or Nx just to link them together locally. Since npm 7, there’s a simpler option built directly into the package manager you already have: workspaces.

This guide walks through setting up a monorepo with npm workspaces, sharing code between packages without publishing, running scripts across packages, and the gotchas that trip people up the first time.

What npm Workspaces Actually Do

A workspace is just a folder with its own package.json that npm treats as part of a larger project. When you run npm install at the root, npm:

  • Installs all dependencies for every workspace into a single top-level node_modules (hoisting shared dependencies to avoid duplication)
  • Symlinks any workspace package that’s referenced by name from another workspace, so you don’t need to publish it anywhere to use it
  • Lets you run scripts in one workspace, several, or all of them from the root with a single command

No extra CLI to install, no config file beyond what’s already in package.json.

Setting Up the Root package.json

Start with a root directory that will contain your packages. The root package.json declares where the workspaces live:

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

The private: true field matters — it stops npm from accidentally trying to publish the root package itself, since the root is just a container, not something you’d ever ship.

Now create the folder structure:

my-monorepo/
  package.json
  packages/
    utils/
      package.json
      index.js
    api/
      package.json
      index.js

Each package under packages/ gets its own normal package.json:

{
  "name": "@my-monorepo/utils",
  "version": "1.0.0",
  "main": "index.js"
}

Using a scope like @my-monorepo/ is a convention, not a requirement, but it makes it obvious at a glance which packages are internal to this repo versus external dependencies from the registry.

Linking Packages to Each Other

Suppose the api package needs to use utils. Add it as a normal dependency in packages/api/package.json:

{
  "name": "@my-monorepo/api",
  "version": "1.0.0",
  "dependencies": {
    "@my-monorepo/utils": "*"
  }
}

Then run npm install from the repo root. Instead of trying to fetch @my-monorepo/utils from the npm registry, npm recognizes it as a sibling workspace and creates a symlink in node_modules/@my-monorepo/utils pointing back to packages/utils. Any edit you make to the utils package is immediately visible to the api package — no rebuild, no republish, no npm link dance.

You can verify the link worked with:

npm ls @my-monorepo/utils --workspaces

Running Scripts Across Workspaces

The --workspace (single) and --workspaces (all) flags let you target scripts precisely from the root:

# Run the "test" script in just the api package
npm run test --workspace=@my-monorepo/api

# Run "build" in every workspace that defines it
npm run build --workspaces --if-present

# Install a dependency into one specific workspace
npm install lodash --workspace=@my-monorepo/utils

The --if-present flag is important for the all-workspaces case: without it, npm errors out the moment it hits a workspace that doesn’t define that script. With it, npm silently skips packages that don’t have a matching script, which is what you want when, say, only some of your packages have tests.

Hoisting and Why Your Lockfile Looks Different

By default, npm tries to hoist dependencies shared across workspaces into the single root node_modules, rather than duplicating them inside each workspace folder. This keeps install size down and speeds up installs, but it means a workspace’s own node_modules folder (if you look for one) will often be empty or missing entirely — the actual files live at the root.

This is usually invisible day-to-day since Node’s module resolution walks up parent directories automatically. But it causes two common surprises:

  • Phantom dependencies: a workspace can accidentally require() a package it never declared, just because some other workspace happens to depend on it and it got hoisted to the shared root. This works locally but breaks if you ever extract that package to publish it standalone.
  • Version conflicts: if two workspaces need incompatible versions of the same dependency, npm can’t hoist both — one stays nested inside the workspace that needs the non-hoisted version, which is correct but can look confusing in node_modules.

The single package-lock.json at the repo root tracks all of this. Don’t add per-workspace lockfiles — there should be exactly one, at the root, for the whole monorepo.

A Common Pitfall: Editing the Wrong package.json

When you run a plain npm install some-package from inside a workspace folder (not the root), it still works, but it’s easy to forget which directory your shell is in and accidentally add a dependency to the wrong package.json. The safer habit is to always run install commands from the repo root using --workspace=<name> explicitly, so the target is unambiguous and visible in your shell history.

When Workspaces Aren’t Enough

npm workspaces solve dependency linking and script running well, but they don’t do task orchestration (like only rebuilding packages affected by a change), remote caching, or dependency graph visualization. If your monorepo grows past a handful of packages and CI times start creeping up, tools like Turborepo or Nx build on top of the same workspace mechanism — they don’t replace it. Starting with plain npm workspaces first, and only reaching for a task runner once you feel real pain, keeps the setup understandable for as long as possible.

Conclusion

npm workspaces give you real monorepo ergonomics — shared installs, symlinked local packages, and cross-package script running — using tooling you already have installed. Start with a root workspaces field, reference sibling packages by name in dependencies, and use --workspace / --workspaces flags to run scripts precisely. It won’t replace a dedicated build orchestrator forever, but for most projects it’s the right amount of tooling to start with.

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted