npm — Workspaces & Monorepos
A monorepo (monolithic repository) stores multiple related projects in a single repository. npm workspaces provide built-in support for managing monorepos by allowing you to install dependencies for multiple packages with a single npm install command.
This guide covers setting up npm workspaces, the workspace protocol, dependency hoisting, comparing workspace implementations, and integrating with monorepo tools like Turborepo and Nx.
npm workspaces (introduced in npm v7) allow you to define a set of packages within a single root package.json. When you run npm install at the root, npm installs dependencies for all workspace packages simultaneously and symlinks interdependent packages.
Key benefits of workspaces:
One npm install installs all dependencies for all packages
Interdependent packages are symlinked, so changes are immediately reflected
Shared dependencies are hoisted to the root node_modules, reducing duplication
Single package-lock.json for the entire monorepo
To set up npm workspaces, add the workspaces field to your root package.json. This field accepts an array of glob patterns pointing to your packages.
| 1 | // Root package.json |
| 2 | { |
| 3 | "name": "my-monorepo", |
| 4 | "private": true, |
| 5 | "workspaces": [ |
| 6 | "packages/*", |
| 7 | "apps/*", |
| 8 | "shared/*" |
| 9 | ] |
| 10 | } |
| 11 | |
| 12 | // Directory structure: |
| 13 | // my-monorepo/ |
| 14 | // package.json (root, with workspaces config) |
| 15 | // packages/ |
| 16 | // ui/ (package name: @my/ui) |
| 17 | // package.json |
| 18 | // utils/ (package name: @my/utils) |
| 19 | // package.json |
| 20 | // apps/ |
| 21 | // web/ (package name: @my/web) |
| 22 | // package.json |
| 23 | // api/ (package name: @my/api) |
| 24 | // package.json |
| 25 | // shared/ |
| 26 | // config/ (package name: @my/config) |
| 27 | // package.json |
| 28 | // node_modules/ |
| 29 | // package-lock.json |
Each package has its own package.json with a unique name:
| 1 | // packages/ui/package.json |
| 2 | { |
| 3 | "name": "@my/ui", |
| 4 | "version": "1.0.0", |
| 5 | "dependencies": { |
| 6 | "react": "^18.3.0", |
| 7 | "@my/utils": "*" |
| 8 | } |
| 9 | } |
| 10 | |
| 11 | // apps/web/package.json |
| 12 | { |
| 13 | "name": "@my/web", |
| 14 | "version": "1.0.0", |
| 15 | "dependencies": { |
| 16 | "@my/ui": "*", |
| 17 | "@my/utils": "*", |
| 18 | "next": "^14.0.0" |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // Install everything from root: |
| 23 | cd my-monorepo && npm install |
| 24 | // Installs deps for all 4 packages, symlinks @my/ui, @my/utils |
npm workspaces support the workspace: protocol for specifying inter-package dependencies. This protocol ensures the dependency is always resolved from the local workspace, not the registry.
| 1 | // Using workspace: protocol (npm v8+) |
| 2 | { |
| 3 | "dependencies": { |
| 4 | "@my/ui": "workspace:*", |
| 5 | "@my/utils": "workspace:^", |
| 6 | "@my/config": "workspace:1.0.0" |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | // workspace:* — Always use the local workspace version |
| 11 | // workspace:^ — Use local workspace, allow minor/patch |
| 12 | // workspace:~ — Use local workspace, allow patch only |
| 13 | // workspace:1.0.0 — Use local workspace at exact version |
| 14 | |
| 15 | // Before publishing, workspace: dependencies are |
| 16 | // automatically replaced with the actual version: |
| 17 | // "workspace:*" -> "1.0.0" |
| 18 | // "workspace:^" -> "^1.0.0" |
| 19 | |
| 20 | // This is done by npm publish or npm pack. |
| 21 | // If the workspace version is 1.0.0: |
| 22 | // workspace:* -> 1.0.0 |
| 23 | // workspace:^ -> ^1.0.0 |
| 24 | // workspace:~ -> ~1.0.0 |
| 25 | // workspace:1.0.0 -> 1.0.0 |
info
npm workspaces use a hoisting algorithm to minimize disk usage. Shared dependencies are hoisted to the root node_modules, while conflicting versions are kept in individual package directories.
| 1 | // node_modules layout with hoisting: |
| 2 | my-monorepo/ |
| 3 | node_modules/ |
| 4 | react/ ← hoisted (shared by multiple packages) |
| 5 | lodash/ ← hoisted |
| 6 | next/ ← hoisted |
| 7 | .package-lock.json |
| 8 | packages/ |
| 9 | ui/ |
| 10 | node_modules/ ← only if react version conflicts |
| 11 | react/ ← different version than root |
| 12 | package.json |
| 13 | utils/ |
| 14 | package.json ← no node_modules, uses hoisted deps |
| 15 | config/ |
| 16 | package.json |
| 17 | |
| 18 | // How npm decides: |
| 19 | // 1. Same version across packages → hoist to root |
| 20 | // 2. Different versions → keep in package's node_modules |
| 21 | // 3. Nested workspace packages → not hoisted (always symlinked) |
| 22 | |
| 23 | // pnpm uses a different approach: content-addressable store |
| 24 | // npm and yarn use hoisting (same algorithm) |
warning
npm workspaces provide several ways to run scripts across some or all packages:
| 1 | # Run a script in every workspace |
| 2 | npm run build --workspaces |
| 3 | |
| 4 | # Run in specific workspaces |
| 5 | npm run test --workspace=@my/ui --workspace=@my/utils |
| 6 | |
| 7 | # Run in all workspaces except some |
| 8 | npm run lint --workspaces --if-present |
| 9 | # --if-present: skip packages that don't have the script |
| 10 | |
| 11 | # Run a command from a specific workspace context |
| 12 | npm run dev -w @my/web |
| 13 | |
| 14 | # Run install only for specific workspaces |
| 15 | npm install -w @my/ui -w @my/utils |
| 16 | |
| 17 | # Execute command in workspace (npm 9+) |
| 18 | npm exec --workspace=@my/ui -- vitest run |
| 19 | |
| 20 | # Run in order (dependencies first) |
| 21 | npm run build --workspaces --include-workspace-root |
| 22 | |
| 23 | # List workspaces |
| 24 | npm ls --workspaces |
| 25 | npm ls -w @my/ui # tree for specific workspace |
| 26 | |
| 27 | # Check outdated deps across workspaces |
| 28 | npm outdated --workspaces |
npm, Yarn, and pnpm all support workspaces, but they differ in their approach to dependency management:
| Feature | npm | Yarn | pnpm |
|---|---|---|---|
| Introduced | v7 (2020) | v2 (2020) | v5 (2021) |
| Hoisting | Yes (flat) | Yes (flat) | No (content-addressable store) |
| Strict isolation | No | No | Yes (strict) |
| Disk usage | Moderate (hoisted) | Moderate (hoisted) | Low (single store, hard links) |
| Install speed | Fast | Fast (with PnP: fastest) | Fast (cached store) |
| workspace: protocol | Yes (v8+) | Yes (v2+) | Yes (v5+) |
| Lock file | package-lock.json | yarn.lock | pnpm-lock.yaml |
info
npm workspaces handle dependency management, but monorepo tools like Turborepo and Nx add task orchestration — caching, parallel execution, and dependency-aware build ordering.
| 1 | // Turborepo — turbo.json |
| 2 | { |
| 3 | "$schema": "https://turbo.build/schema.json", |
| 4 | "tasks": { |
| 5 | "build": { |
| 6 | "dependsOn": ["^build"], |
| 7 | "inputs": ["src/**", "tsconfig.json"], |
| 8 | "outputs": ["dist/**"], |
| 9 | "cache": true |
| 10 | }, |
| 11 | "test": { |
| 12 | "dependsOn": ["build"], |
| 13 | "inputs": ["src/**", "tests/**"], |
| 14 | "cache": true |
| 15 | }, |
| 16 | "lint": { |
| 17 | "dependsOn": [], |
| 18 | "cache": true |
| 19 | } |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // Run tasks with Turborepo: |
| 24 | npx turbo build # build all, respecting dependency order |
| 25 | npx turbo test --filter=@my/ui # test only @my/ui |
| 26 | npx turbo lint test # run lint then test in parallel |
| 1 | // Nx — nx.json |
| 2 | { |
| 3 | "tasksRunnerOptions": { |
| 4 | "default": { |
| 5 | "runner": "nx/tasks-runners/default", |
| 6 | "options": { |
| 7 | "cacheableOperations": ["build", "test", "lint"] |
| 8 | } |
| 9 | } |
| 10 | }, |
| 11 | "targetDefaults": { |
| 12 | "build": { |
| 13 | "dependsOn": ["^build"], |
| 14 | "outputs": ["{projectRoot}/dist"] |
| 15 | } |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | // Use with npm workspaces: |
| 20 | npx nx run-many --target=build --parallel=3 |
| 21 | npx nx affected:test # only test changed projects |
| 22 | npx nx graph # visualize project dependencies |
pro tip
Use workspace:* for Inter-Package Dependencies
Always use workspace:* for dependencies between packages in your monorepo. npm automatically resolves these to the correct local versions.
Keep the Root package.json Lean
The root package.json should be private: true and contain only shared devDependencies and workspace configuration. Never publish the root package.
Enforce Dependency Declarations
Use ESLint rules like import/no-extraneous-dependencies to ensure each package declares all its dependencies. This prevents phantom dependency bugs that only appear when hoisting changes.
Use a Task Runner for Large Monorepos
For monorepos with more than 10 packages, add Turborepo or Nx. Their caching and parallel execution can reduce CI times from hours to minutes.