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
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.jsonDeclaring workspaces
The root package.json lists the workspace globs. It is itself private — you never publish the root:
Root package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*"],
"devDependencies": { "typescript": "^5.4.0" }
}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
{
"name": "@acme/api",
"dependencies": { "@acme/utils": "workspace:*", "express": "^4.19.2" }
}What install creates
node_modules/
├─ express/ ← from registry, hoisted, shared by all
└─ @acme/
└─ utils → symlink to ../../packages/utils ← the LOCAL packageRunning commands across workspaces
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 |
|---|---|
| Run in every workspace |
| Run in one named workspace |
| Also run in the root package |
Why a single lockfile matters
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 |
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.