Articles

npm and pnpm Workspaces: Managing a Monorepo

Workspaces let npm and pnpm manage multiple packages in one repository, sharing a single dependency tree and letting packages reference each other locally.

Takina Takina · · 4 min read
Code editor showing project file structure

Workspaces are a package manager feature that lets multiple packages live in one repository and be installed, linked, and managed together, instead of each package having its own separate node_modules and its own separate install step. Both npm and pnpm support the concept, though with meaningfully different implementations underneath — which matters once a repository grows past a handful of packages.

The problem workspaces solve

A monorepo holding several related packages — say, a shared UI component library, a backend API, and a frontend app that depends on both — creates two recurring headaches without tooling support. First, cross-package dependencies: the frontend app needs to import from the component library, but that library isn’t published to a registry, it’s sitting in the sibling directory right there in the same repo. Second, shared dependencies: if all three packages use the same version of a library like React or TypeScript, installing it three separate times wastes disk space and, worse, can let versions drift out of sync between packages that are supposed to be consistent.

Workspaces solve both. Declaring a package as a workspace member makes the package manager symlink it into a shared node_modules at the repo root, so other workspace packages can import from it exactly like any other installed dependency — no publishing, no manual npm link juggling. And dependencies common across packages get hoisted to a single shared location instead of duplicated per package.

Setting up npm workspaces

npm workspaces are declared in the root package.json:

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

Each subdirectory matching that glob with its own package.json becomes a workspace member. Running npm install from the root installs every workspace’s dependencies in one pass, deduplicating shared ones into the root node_modules where versions allow it. To add a dependency to one specific workspace, use the -w (or --workspace) flag:

npm install lodash -w packages/api

And to reference one workspace package from another, you add it as a normal dependency with a version — npm resolves it to the local workspace copy automatically rather than fetching from the registry, as long as the version ranges are compatible.

pnpm workspaces

pnpm uses a separate pnpm-workspace.yaml file at the repo root rather than a field in package.json:

packages:
  - "packages/*"
  - "apps/*"

Functionally it does the same job — cross-linking local packages and managing dependencies together — but pnpm’s underlying storage model is different from npm’s. Rather than duplicating package contents into each project’s node_modules, pnpm keeps a single global content-addressable store on disk and uses hard links and symlinks to construct each package’s node_modules, which tends to make disk usage and install times noticeably better in workspaces with many packages sharing many dependencies. This structural difference — not just the config file format — is the main reason teams choose pnpm specifically for larger monorepos.

npm vs. pnpm workspaces at a glance

npm workspacespnpm workspaces
Config locationworkspaces field in package.jsonSeparate pnpm-workspace.yaml
Dependency storageHoisted into shared node_modulesGlobal content-addressable store, linked in
Disk efficiency at scaleGoodGenerally better for many packages
Phantom dependency riskHigher — hoisting can expose undeclared depsLower — stricter node_modules structure by default
Built-in toolShips with npm, no extra installRequires installing pnpm separately

The “phantom dependency” row is worth understanding: npm’s flat hoisting can let a package import something it never declared as a dependency, simply because some sibling package pulled it in and hoisting placed it at a level where it’s accidentally reachable. That works until the sibling package removes the dependency, and the import silently breaks in a package that never listed it in the first place. pnpm’s stricter, symlink-based structure is intentionally designed to prevent this class of bug — a package can only resolve dependencies it actually declared.

Workspaces and build orchestration

Workspaces themselves only handle installation and local linking — they don’t handle running builds in dependency order, caching build outputs, or only rebuilding what changed. That’s the job of a separate task runner layered on top, like Nx or Turborepo, which read the same workspace structure to figure out which packages depend on which, and use that graph to parallelize and cache builds intelligently. Workspaces and a monorepo build tool solve adjacent but distinct problems: one is about dependency management, the other about task orchestration.

A note on lockfiles

Both tools produce a single lockfile at the repo root covering every workspace, rather than one lockfile per package. This is a deliberate tradeoff for consistency — every workspace resolves shared dependencies to exactly the same versions — but it also means a change in one package’s dependencies can, in principle, shift the resolved versions available to another, which is worth keeping in mind when reviewing a lockfile diff that touches more than the package you actually edited.

The takeaway

Workspaces turn a collection of separately managed packages into a single coherent install, with local packages linking to each other directly instead of going through a registry. npm workspaces work well out of the box with zero extra tooling; pnpm’s workspace implementation trades a bit of setup for meaningfully better disk efficiency and stricter dependency isolation as a monorepo grows. Either way, workspaces are the dependency-management layer — pair them with a real build orchestrator once the monorepo is large enough that rebuilding everything on every change stops being acceptable.

Takina Takina · · 5 min read

toSorted, toReversed, with(): JS's New Array Methods

toSorted(), toReversed(), toSpliced(), and with() copy an array instead of mutating it, fixing a long-standing footgun in JavaScript's Array API.

#JavaScript #Web Development #Developer Tools