Skip to content

npm Workspaces ​

Manage multiple packages in a single repository using npm's built-in workspace support. Each workspace has its own package.json and can depend on sibling workspaces through file: protocol references.

When to use ​

Use npm workspaces when a repository contains multiple packages that need to import from each other without publishing to a registry. Workspaces give you a single node_modules tree and lockfile for the entire repository while letting each package maintain its own package.json and target different runtimes.

The pattern ​

Root package.json ​

List workspace directories in the "workspaces" field:

json
{
  "name": "@scope/app",
  "private": true,
  "type": "module",
  "workspaces": [
    "cli",
    "models",
    "server",
    "shared",
    "shared/browser",
    "shared/nodejs",
    "shared/universal",
    "ui"
  ]
}

Set "private": true on the root — it is a container, not a publishable package. Each entry in "workspaces" is a directory containing its own package.json.

Workspace package.json ​

Each workspace declares its own name and dependencies:

json
{
  "name": "@scope/server",
  "type": "module",
  "dependencies": {
    "@scope/shared": "file:../shared",
    "@scope/models": "file:../models"
  }
}

The "file:" protocol creates a symlink in node_modules pointing to the local directory. No publishing needed — changes are reflected immediately.

Cross-workspace imports ​

Import from sibling workspaces using the package name:

js
import { sh } from "@scope/shared/nodejs/process";
import { Enum } from "@scope/shared/universal/enum";
import { query } from "@scope/shared/nodejs/db";

These resolve through the symlinks that npm creates in the root node_modules.

Repository layout ​

A typical workspace repository follows this structure:

my-app/
├── package.json          ← root, lists workspaces
├── cli/
│   └── package.json      ← @scope/cli
├── models/
│   └── package.json      ← @scope/models
├── server/
│   └── package.json      ← @scope/server
├── shared/
│   ├── package.json      ← @scope/shared (umbrella)
│   ├── browser/
│   │   └── package.json  ← @scope/shared-browser
│   ├── nodejs/
│   │   └── package.json  ← @scope/shared-nodejs
│   └── universal/
│       └── package.json  ← @scope/shared-universal
└── ui/
    └── package.json      ← @scope/ui

Environment-based code separation ​

Split shared code by runtime target:

shared/
├── package.json        ← umbrella with subpath exports
├── browser/            ← browser-only: DOM, fetch, RPC
│   └── package.json
├── nodejs/             ← Node.js-only: DB, file I/O
│   └── package.json
└── universal/          ← pure logic: schemas, enums
    └── package.json

The umbrella shared/package.json exposes all three via subpath exports:

json
{
  "name": "@scope/shared",
  "type": "module",
  "exports": {
    "./universal/*": "./universal/*.js",
    "./nodejs/*": "./nodejs/*.js",
    "./browser/*": "./browser/*.js"
  }
}

Consumers import through the umbrella:

js
import { schema } from "@scope/shared/universal/schema";
import { cjsify } from "@scope/shared/nodejs/module";

Each nested directory is also a standalone workspace, allowing it to declare its own dependencies (e.g., shared/nodejs/ depends on better-sqlite3 but shared/browser/ does not).

Workspace-scoped scripts ​

Run scripts in specific workspaces from the root:

bash
# Build only the UI workspace
npm run build --workspace ui

# Run tests in the server workspace
npm test --workspace server

# Install a dependency in a specific workspace
npm install express --workspace server

Self-hosting CLI ​

Make the project's own CLI a workspace:

json
{
  "workspaces": ["cli", "server", "ui"],
  "scripts": {
    "start": "my-cli start",
    "test": "my-cli test"
  },
  "dependencies": {
    "@scope/cli": "file:./cli"
  }
}

The root scripts invoke the CLI binary, which is symlinked into node_modules/.bin/ by npm. The CLI has full access to other workspaces.

Trade-offs ​

Benefits:

  • Single npm install at the root installs everything
  • One package-lock.json ensures consistent dependency versions across all workspaces
  • Cross-workspace imports use package names, not fragile relative paths
  • Adding a new workspace is one directory and one line in the root "workspaces" array

Costs:

  • All workspaces share a single node_modules tree. If two workspaces need conflicting versions of the same dependency, npm must nest one copy, which can cause unexpected behavior.
  • npm install in a workspace directory still resolves from the root lockfile. Running npm install from a workspace subdirectory can modify the root lockfile unexpectedly — always install from the root.
  • Nested workspaces (e.g., shared/browser/ inside shared/) add complexity. npm supports them, but some tools may not resolve them correctly.
  • CI pipelines must install all workspaces even when only one changed. Use --workspace flags or a tool like Turborepo for selective builds.