Appearance
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/uiEnvironment-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.jsonThe 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 serverSelf-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 installat the root installs everything - One
package-lock.jsonensures 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_modulestree. If two workspaces need conflicting versions of the same dependency, npm must nest one copy, which can cause unexpected behavior. npm installin a workspace directory still resolves from the root lockfile. Runningnpm installfrom a workspace subdirectory can modify the root lockfile unexpectedly — always install from the root.- Nested workspaces (e.g.,
shared/browser/insideshared/) 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
--workspaceflags or a tool like Turborepo for selective builds.