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.