pnpm Workspaces: Monorepo Management Without the Overhead
Monorepos — multiple packages in one repository — solve the problem of managing shared code across related projects. pnpm workspaces make this practical with one of the fastest package managers and a symlink-based approach that dramatically reduces disk usage and install times.
If you're managing a frontend app, backend API, and shared types in separate repos and constantly fighting version mismatches, pnpm workspaces are worth understanding.
Why pnpm for Workspaces
Speed: pnpm is significantly faster than npm and yarn for installs, especially in monorepos with many packages.
Disk efficiency: pnpm uses a content-addressable store and hard links. If 100 packages all depend on lodash 4.17, only one copy of lodash exists on disk. npm and yarn create separate copies in each node_modules.
Strict by default: pnpm prevents accessing packages that aren't listed in your package.json. This prevents "phantom dependencies" — accidentally using packages that aren't explicitly declared.
Native workspace protocol: workspace:* references work reliably across pnpm, without the quirkiness of npm/yarn workspace handling.
Basic Workspace Setup
Repository structure:
my-app/
├── package.json # root workspace config
├── pnpm-workspace.yaml
├── packages/
│ ├── web/ # Next.js frontend
│ │ └── package.json
│ ├── api/ # Express backend
│ │ └── package.json
│ └── types/ # Shared TypeScript types
│ └── package.json
└── apps/ # optional second category
Root pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*'
Root package.json:
{
"name": "my-monorepo",
"private": true,
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"scripts": {
"dev": "pnpm --parallel -r dev",
"build": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint"
}
}
Package Setup with workspace: Protocol
In packages/web/package.json:
{
"name": "@my-org/web",
"version": "0.0.1",
"private": true,
"dependencies": {
"@my-org/types": "workspace:*",
"next": "^14.0.0",
"react": "^18.0.0"
}
}
workspace:* tells pnpm to use the local workspace package instead of fetching from npm. This is the key to cross-package references in a monorepo.
In packages/types/package.json:
{
"name": "@my-org/types",
"version": "0.0.1",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5"
}
}
TypeScript Configuration for Monorepos
Create a base tsconfig.json at the root:
// tsconfig.json (root)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
Each package extends it:
// packages/web/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
TypeScript project references (optional but recommended for type-checking performance):
// tsconfig.json (root)
{
"files": [],
"references": [
{ "path": "./packages/types" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
With project references, tsc --build builds packages in dependency order, and type checking is incremental.
Running Commands
Install all dependencies:
pnpm install # from root
Run a script in all packages:
pnpm -r build # build all packages
pnpm -r --parallel dev # run dev in all packages concurrently
Run a script in a specific package:
pnpm --filter @my-org/web dev
pnpm --filter @my-org/web build
Run in a package directory:
cd packages/web && pnpm dev
# or:
pnpm --filter @my-org/web run dev
Add a dependency to a specific package:
pnpm --filter @my-org/web add react-query
pnpm --filter @my-org/api add --save-dev @types/node
Add a dependency to the root (dev tools shared across all packages):
pnpm add -w -D typescript prettier
Shared Dev Tools
Put dev tools used across packages at the root level:
// package.json (root)
{
"devDependencies": {
"typescript": "^5",
"prettier": "^3",
"eslint": "^8",
"turbo": "^2" // if using Turborepo
}
}
Packages import root-level tools automatically due to pnpm's hoisting behavior. Override the hoist behavior in .npmrc:
hoist-pattern[]=*
Turborepo Integration
For large monorepos, add Turborepo on top of pnpm workspaces for caching and build graph optimization:
pnpm add -w -D turbo
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"]
}
}
}
Turborepo caches task outputs and only re-runs tasks when inputs change. On CI, this means a turbo build only rebuilds packages that have changed since the last build.
pnpm Workspaces vs. npm/yarn Workspaces
| Feature | pnpm | npm | yarn (berry) |
|---|---|---|---|
| Speed | Fast | Slow-moderate | Fast |
| Disk usage | Very efficient (hard links) | Per-package node_modules | Zero-install (PnP) |
| Strict dependencies | Yes | No | Yes |
| workspace: protocol | Yes | No | Yes |
| Plug'n'Play | No | No | Yes (default in v2+) |
| Compatibility | High | Full | Mixed (PnP has issues) |
pnpm is the recommended choice for new monorepos. npm workspaces work but are slower. Yarn Berry's Plug'n'Play mode is powerful but has compatibility issues with some tools.
Common Monorepo Patterns
Shared config package:
packages/
config/
src/
eslint.js
tsconfig.json
vitest.config.ts
package.json
Each package imports config from @my-org/config and extends it.
Internal packages that shouldn't be published:
Add "private": true to their package.json. pnpm won't publish them.
Version management:
For apps where packages don't need semver (internal only), use "version": "0.0.0" as a placeholder. For packages that get published, use Changesets.
.npmrc Configuration
# .npmrc
link-workspace-packages=true # link workspace deps by default
hoist-pattern[]=* # hoist all packages to root node_modules
shamefully-hoist=false # keep strict (don't ghost deps into apps)
strict-peer-dependencies=false # less noisy about peer deps in dev
pnpm workspaces scale from small 2-3 package repos to large codebases with hundreds of packages. The combination of speed, disk efficiency, and strict dependency resolution makes it the package manager of choice for serious monorepo setups.