Why pnpm Over npm or Yarn?
Three reasons pnpm wins for monorepos in 2026:
- Content-addressable store — each version of a package is stored once globally, referenced by symlink. A monorepo with 10 apps sharing React doesn't store 10 copies of React.
- Strict node_modules — packages can only import what is in their own
package.json. Phantom dependencies (importing a package that happens to be installed by a sibling) cause runtime errors rather than silently working. - Catalogs — new in v9, define shared dependency versions at the workspace root to keep all packages in sync.
Workspace Setup
# pnpm-workspace.yaml (root)
packages:
- "apps/*"
- "packages/*"
// package.json (root)
{
"name": "my-monorepo",
"private": true,
"engines": { "node": ">=20.9.0", "pnpm": ">=9" }
}
Catalogs — pnpm v9 Feature
Catalogs define shared dependency versions in one place. No more version drift across packages:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: "^19.0.0"
react-dom: "^19.0.0"
typescript: "^5.5.0"
zod: "^3.23.0"
// apps/web/package.json
{
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}
Update all packages at once: change the version in pnpm-workspace.yaml and run pnpm install.
Common Workspace Commands
# Install all workspace dependencies
pnpm install
# Add a dependency to a specific package
pnpm --filter web add react-query
# Add a shared devDependency to the root
pnpm add -D -w typescript
# Run build in all packages
pnpm -r run build
# Run build in all packages in dependency order
pnpm -r --workspace-concurrency=4 run build
# Run only in packages with changes (combine with Turborepo)
pnpm --filter "...[origin/main]" run build
# Run in a specific package
pnpm --filter web run dev
pnpm patch for Patching Dependencies
When a package has a bug and no fix is released yet:
# Create a patch
pnpm patch some-package@1.2.3
# Edit the files in the temp directory it creates
# Then apply the patch
pnpm patch-commit /path/to/temp-dir
This creates a .patches/some-package@1.2.3.patch file and adds it to pnpm-workspace.yaml:
patchedDependencies:
some-package@1.2.3: patches/some-package@1.2.3.patch
Production Docker With pnpm deploy
pnpm deploy creates a standalone deployment directory with only the production dependencies for one package:
FROM node:22-alpine AS base
RUN npm install -g pnpm@9
FROM base AS builder
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter web build
FROM base AS runner
WORKDIR /app
# Deploy only web's production deps — no dev deps, no other packages
COPY --from=builder /app .
RUN pnpm deploy --filter=web --prod /deploy/web
FROM node:22-alpine AS final
WORKDIR /app
COPY --from=runner /deploy/web .
COPY --from=builder /app/apps/web/.next .next
CMD ["node", "server.js"]
Strict vs Hoisted Mode
pnpm's default (strict) prevents packages from accessing unlisted dependencies. If you have legacy code that relies on hoisted deps:
# .npmrc
hoist=false # default — strict (recommended)
# hoist=true # legacy — matches npm/yarn behavior
shamefully-hoist=false
Keep hoist=false in new projects. Fix the phantom dependency errors — they are real bugs waiting to happen in production.
References: pnpm · workspace docs · v9 changelog