NodeJSMonorepos & Workspaces

Monorepos & Workspaces

A monorepo holds many related packages — apps, shared libraries, configs — in a single repository. Workspaces are the package-manager feature that makes this practical: one install for the whole repo, shared dependencies hoisted, and local packages linked together so a change in your utils library is instantly visible to the app that uses it, with no publishing in between.

The shape of a monorepo

A typical workspace layout

Text
my-monorepo/
   ├─ package.json          ← the ROOT: declares workspaces, dev tooling
   ├─ package-lock.json     ← ONE lockfile for the whole repo
   ├─ node_modules/         ← ONE install, shared deps hoisted here
   └─ packages/
       ├─ utils/            ← @acme/utils   (a shared library)
       │   └─ package.json
       ├─ api/              ← @acme/api     (depends on @acme/utils)
       │   └─ package.json
       └─ web/              ← @acme/web     (depends on @acme/utils)
           └─ package.json
Declaring workspaces

The root package.json lists the workspace globs. It is itself private — you never publish the root:

Root package.json

JSON
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": { "typescript": "^5.4.0" }
}
The root must be `private: true`
A workspace root is organizational scaffolding, not a publishable package. Marking it `"private": true` prevents `npm publish` from ever uploading your entire monorepo by accident — and most workspace tooling refuses to operate without it.
The magic: local packages link automatically

When @acme/api lists @acme/utils as a dependency, npm sees that utils is a workspace and symlinks it into node_modules instead of downloading from the registry. Edit utils, and api sees the change immediately — no build-publish-reinstall loop:

packages/api/package.json

JSON
{
  "name": "@acme/api",
  "dependencies": { "@acme/utils": "workspace:*", "express": "^4.19.2" }
}

What install creates

Text
node_modules/
   ├─ express/                    ← from registry, hoisted, shared by all
   └─ @acme/
       └─ utils → symlink to ../../packages/utils   ← the LOCAL package
The `workspace:*` protocol
`"@acme/utils": "workspace:*"` tells the package manager "always use the local copy, any version". On `npm publish`, this protocol is automatically rewritten to a real semver range (e.g. `^1.2.0`) so external consumers get a normal dependency. It is the cleanest way to express "this is an internal package". (npm also accepts a plain `"*"`; pnpm and yarn require the explicit `workspace:` form.)
Running commands across workspaces

Bash
npm install                                  # ONE install for every workspace
npm run build --workspaces                   # run "build" in every package
npm run test -w @acme/api                    # run a script in just one
npm install lodash -w @acme/web              # add a dep to one workspace
npm exec --workspaces -- eslint .            # run a binary across all

Flag

Scope

--workspaces (-ws)

Run in every workspace

-w <name>

Run in one named workspace

--include-workspace-root

Also run in the root package

Why a single lockfile matters
One lockfile, one node_modules — that is the point
A monorepo has exactly *one* `package-lock.json` at the root governing every workspace. This guarantees all packages resolve the same version of a shared dependency — no "api uses lodash 4.1, web uses lodash 4.9" drift. Never commit per-package lockfiles inside a workspace; delete them if a tool generates one.
When you outgrow plain workspaces

Native workspaces handle linking and installing, but not task orchestration — running builds in dependency order, caching unchanged work, only rebuilding what changed. For large monorepos, dedicated tools add that layer on top:

Tool

Adds

Turborepo

Task graph, remote caching, "only rebuild what changed"

Nx

Task orchestration, affected-detection, generators, plugins

pnpm workspaces

Strict linking + disk-efficient store (see Yarn & pnpm)

Changesets

Coordinated versioning & changelogs across packages

Start simple
Don't reach for Nx or Turborepo on day one. Plain `workspaces` carries you a long way — add an orchestrator only when build times or release coordination genuinely hurt. The complexity should follow the pain, not precede it.
Monorepo vs polyrepo
  • Monorepo wins at atomic cross-package changes, shared tooling, and refactoring across boundaries in one commit.

  • Monorepo costs are CI complexity, slower clones at scale, and the need for orchestration tooling as it grows.

  • Polyrepo wins at independent release cadences and strict team boundaries — but pays the price in version-sync overhead.

Section complete
That wraps up npm & Package Management. Next we open the standard library itself: [Core Modules Overview](/nodejs/core-modules-overview).