../

pnpm & monorepos

pnpm 12.x (12.7, Sept 2026: the native Rust rewrite; 11.x still gets fixes) and how to lay out, link, build, version and ship a TypeScript monorepo. Bun workspaces are compared at the end; the Bun-only side lives in Bun.

Install & pin

curl -fsSL https://get.pnpm.io/install.sh | sh -
npx get-pnpm                  # via npm (Node 22.13+)
pnpm self-update              # latest; or self-update 12.7.0
pnpm --version
 
corepack enable pnpm          # Node 24 only: Corepack
                              # isn't bundled from Node 25
FactDetail
Binarypnpm 12 is a native executable: no Node needed to run pnpm itself
Aliasespn for pnpm, pnx for pnpm dlx
Project pinpnpm init writes "packageManager": "pnpm@12.x" and devEngines.packageManager with onFail: "download", so any pnpm switches to the pinned version
Node for the projectpnpm runtime set node 24 saves devEngines.runtime; -g installs globally
One-off versionpnpm with 11 install ignores the pin for one command
Dockerghcr.io/pnpm/pnpm:12 (pnpm only, add Node with pnpm runtime set node 24 -g)
MajorChanged
10dependency lifecycle scripts blocked by default; settings could live in pnpm-workspace.yaml
11settings only in pnpm-workspace.yaml or global config.yaml (.npmrc is auth/registry only); allowBuilds replaces onlyBuiltDependencies; minimumReleaseAge defaults to 1 day; SQLite store (store/v11); env prefix pnpm_config_*
12Rust CLI; workspace task scheduler (tasks, pnpm pipeline); pnpm change/version -r release flow; unknown workspace settings are errors when pnpm is pinned

CLI vs npm & Bun

TaskpnpmnpmBun
Install allpnpm inpm ibun i
CI installpnpm ci / pnpm i --frozen-lockfilenpm cibun ci
Add dep / dev deppnpm add zod / add -D vitestnpm i zod / -Dbun add zod / -d
Add to workspace rootpnpm add -w -D typescriptnpm i -D x (at root)bun add -d x (at root)
Add to one packagepnpm --filter api add zodnpm i zod -w apibun add zod --filter api
Removepnpm rm zodnpm uninstall zodbun rm zod
Update (interactive, latest)pnpm up -i -Lnpm updatebun update -i --latest
Outdatedpnpm outdated -rnpm outdatedbun outdated
Run scriptpnpm dev / pnpm run devnpm run devbun dev
Script in all packagespnpm -r buildnpm run build -wsbun --filter '*' build
Run a bin oncepnpm dlx create-vite / pnxnpxbunx
Local binpnpm exec tscnpx tscbunx tsc
Why installedpnpm why reactnpm explain reactbun why react
Auditpnpm audit --fixnpm audit fixbun audit fix
Patch a deppnpm patch x + patch-commitpatch-packagebun patch x + --commit
Clean node_modulespnpm cleanrm -rfrm -rf
Lockfilepnpm-lock.yamlpackage-lock.jsonbun.lock
Import lockfilepnpm importnoneautomatic

node_modules layout

Every file of every package version is stored once in a global content-addressable store and hard-linked (or reflinked) into projects. node_modules holds only your direct deps, as symlinks into a virtual store.

Isolated layout (default)
node_modules/.pnpm/                    # virtual storeexpress@5.1.0/node_modules/express/      # hard links to the storebody-parser/  # symlink to its .pnpm dirbody-parser@2.2.0/express/                  # symlink into .pnpm.modules.yaml

The app's package.json declares only express, so only express appears at the top level; its own dependencies sit next to it inside .pnpm, where Node's resolution finds them.

ConceptMeaning
Storepnpm store path: ~/Library/pnpm/store (macOS), ~/.local/share/pnpm/store (Linux); pnpm store prune cleans it
Strictnesscode can import only what its package.json declares: no phantom dependencies
Phantom depimporting a package you don't declare that npm happened to hoist; pnpm fails at runtime, so add it explicitly
hoistPatterndefault ['*']: everything hoisted into the hidden .pnpm/node_modules, visible to deps only
publicHoistPatternhoist matching deps into the root node_modules for tools that need it (default [])
shamefullyHoist: truenpm-style flat root; last resort for broken tools
nodeLinkerisolated (default), hoisted (flat, no symlinks: React Native, some serverless bundlers), pnp
packageImportMethodauto (hardlink or clone), copy, clone, hardlink
enableGlobalVirtualStoreshare the .pnpm virtual store across projects too (default false)

pnpm-workspace.yaml

Since pnpm 11 this file holds the pnpm settings for the repo (and for a single package too). .npmrc keeps only registry and auth lines.

pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*
  - "!**/test/**"
 
catalog:                  # "react": "catalog:"
  react: ^19.2.0
  zod: ^4.5.0
catalogs:
  react18:                # "react": "catalog:react18"
    react: ^18.3.1
 
allowBuilds:              # deps allowed to run scripts
  esbuild: true
  sharp: true
  core-js: false          # silence, never build
 
minimumReleaseAge: 1440   # minutes: skip versions < 1 day
minimumReleaseAgeExclude:
  - "@acme/*"
 
overrides:
  "lodash@<4.17.21": ^4.17.21
  "express>qs": 6.13.0
  "foo@1>bar": "-"        # remove a transitive dep
 
packageExtensions:
  some-pkg@1:
    peerDependencies:
      react: "*"
 
patchedDependencies:
  left-pad@1.3.0: patches/left-pad@1.3.0.patch
 
publicHoistPattern:
  - "*eslint*"
  - "*prettier*"
SettingDefaultNotes
allowBuildsnone allowedmap of package to true/false; pnpm approve-builds edits it interactively
strictDepBuildstrue (11+)install fails when an unapproved dep wants to build
dangerouslyAllowAllBuildsfalseexactly what it says
minimumReleaseAge1440 (11+)supply-chain delay, in minutes (Bun's is seconds)
blockExoticSubdepstrue (11+)only direct deps may come from git or tarball URLs
trustPolicyoffno-downgrade fails if a version lost provenance/trust
autoInstallPeerstrueinstall missing peer deps
strictPeerDependenciesfalsefail on peer mismatches
linkWorkspacePackagesfalseonly workspace: specs link locally
saveWorkspaceProtocolrollingpnpm add of a workspace package saves workspace:^
injectWorkspacePackagesfalsehard-link workspace deps instead of symlinking (needed for some deploys)
catalogModemanualprefer or strict make pnpm add use catalog entries
verifyDepsBeforeRuninstall (11+)pnpm run installs first if node_modules is stale
engineStrictfalsefail on engines mismatches
.npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}

Global settings go in ~/.config/pnpm/config.yaml. Environment overrides use pnpm_config_<name> (npm_config_* is ignored from pnpm 11).

Workspaces & workspace:

apps/api/package.json
{
  "name": "@acme/api",
  "private": true,
  "type": "module",
  "dependencies": {
    "@acme/db": "workspace:*",
    "@acme/utils": "workspace:^",
    "zod": "catalog:"
  },
  "devDependencies": {
    "@acme/tsconfig": "workspace:*"
  }
}
SpecLinksOn pnpm publish becomes
workspace:*always the local package1.5.0 (exact)
workspace:^local^1.5.0
workspace:~local~1.5.0
workspace:^1.2.0local, must satisfy the range^1.2.0
workspace:@acme/b@*aliasnpm:@acme/b@1.5.0
catalog: / catalog:nameregistry version from the catalogthe catalog range
^1.5.0 (no protocol)registry, unless linkWorkspacePackagesunchanged

The root package.json is private: true and holds repo-wide dev tools (TypeScript, lint, Turborepo); pnpm add at the root needs -w.

Filtering & recursive runs

SelectorPicks
--filter @acme/api / -F apione package (name, or a unique name without scope)
--filter "@acme/*"name glob
--filter "./apps/**" / "{apps}"by path / everything under a directory
--filter "api..."api and everything it depends on
--filter "api^..."only api's dependencies
--filter "...ui"ui and everything that depends on it
--filter "...^ui"only the dependents of ui
--filter "[origin/main]"packages changed since origin/main
--filter "...[origin/main]"changed packages plus their dependents: the "affected" set
--filter "!docs"exclude (combine several --filters)
--filter-prod "api..."like --filter, following dependencies only
--fail-if-no-matchexit 1 when nothing matches
CommandDoes
pnpm -r buildbuild in every package, in dependency (topological) order
pnpm -r --parallel devall at once, ignoring order (long-running watchers)
pnpm -r --workspace-concurrency=2 testcap parallelism
pnpm -r run --dry-run buildprint the task graph
pnpm --filter "api..." buildbuild api plus its deps first
pnpm --filter api exec tsc --noEmitany command inside a package
pnpm -r --stream / --aggregate-outputinterleaved prefixed lines / one block per package
pnpm -r --if-present lintskip packages without the script

--test-pattern "test/**" stops a test-only change from marking dependents as changed; --changed-files-ignore-pattern "**/*.md" ignores docs.

Monorepo structure

apps / packages / tooling
acme/apps/web/               # Next.js apppackage.json   # "@acme/web", privatetsconfig.json  # extends @acme/tsconfigapi/               # Hono on Node or Bunsrc/main.tspackage.jsontsconfig.jsonpackages/ui/                # React componentssrc/index.tspackage.json   # exports ./src/index.tsdb/                # schema + clientutils/tooling/tsconfig/          # shared compiler optionseslint-config/.changeset/            # release notes, if publishingpnpm-workspace.yamlpnpm-lock.yaml         # one lockfile at the rootpackage.json           # private root: dev tools, scriptstsconfig.json          # project references (optional)turbo.json             # task graph + cache (optional)
RuleWhy
apps/ deploy, packages/ are imported, tooling/ configuresclear dependency direction: apps → packages → tooling
Scope every name (@acme/*)no clashes with npm; easy --filter "@acme/*"
Internal packages export TS source ("exports": { ".": "./src/index.ts" })no build step; Next.js (transpilePackages), Vite, Bun and Node 24 type stripping read it directly
Build only what you publishcompiled packages need dist/, .d.ts and project references
Keep one version of React, TS, etc.catalogs; pnpm dedupe --check in CI
Never import across packages by relative pathalways through the package name, so the graph stays honest
Internal package
packages/ui/src/button.tsxindex.ts   # export * from "./button.tsx"package.json   # name, exports, peerDependenciestsconfig.json  # extends @acme/tsconfig

Shared tsconfig & project references

tooling/tsconfig/package.json
{
  "name": "@acme/tsconfig",
  "private": true,
  "files": ["*.json"],
  "exports": {
    "./base.json": "./base.json",
    "./node.json": "./node.json",
    "./react.json": "./react.json"
  }
}
tooling/tsconfig/base.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "types": []
  }
}
packages/utils/tsconfig.json
{
  "extends": "@acme/tsconfig/base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"],
  "references": [{ "path": "../db" }]
}
tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "apps/api" },
    { "path": "packages/utils" },
    { "path": "packages/db" }
  ]
}
PieceDoes
extends: "@acme/tsconfig/base.json"resolves through node_modules and the package exports
composite: truerequired for a referenced project: forces declaration, known file set
referencesbuild order + type-check against the dependency's .d.ts, not its source
tsc -b / tsc -b --watchbuild every referenced project incrementally, from the root
tsc -b --cleandelete outputs
pathsavoid: workspace symlinks + exports already resolve @acme/*

Paths in extendsd files resolve relative to the file that declares them, so keep rootDir, outDir and include in each package. TypeScript 7 (native tsc, 2026) defaults types to [] and strict to true, drops baseUrl and moduleResolution: node, and parallelizes tsc -b with --builders. Source-only internal packages can skip references entirely and let each app type-check the lot with tsc --noEmit.

Task runners

pnpm 12 orders -r runs by the dependency graph and can declare task dependencies and cached pipelines itself; Turborepo and Nx add remote caching and richer affected detection.

pnpm-workspace.yaml
tasks:
  build:
    dependsOn: ["^build"]     # deps' build first
    outputs: ["dist/**"]      # cacheable (pnpm pipeline)
  test:
    dependsOn: ["build"]      # own build first
pipelines:
  default: [build, test, lint]
pnpm -r run build           # follows tasks.dependsOn
pnpm pipeline               # frozen install, affected since
                            # origin/main, cached results
pnpm pipeline --base main --dry-run
turbo.json
{
  "$schema": "https://turborepo.dev/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["build"], "outputs": [] },
    "lint": {},
    "dev": { "cache": false, "persistent": true }
  }
}
ToolCommandsStrength
pnpm -r / pipelinepnpm -r build, pnpm pipelinenothing to install; cache is local
Turborepo 2.xturbo run build, turbo run test --affected, turbo prune web --dockercontent-hash cache, remote cache (Vercel or self-hosted), TUI
Nx 23nx run-many -t build, nx affected -t test, nx graphproject graph, generators, distributed CI

Put the runner in the root package.json scripts ("build": "turbo run build") so pnpm build works everywhere; ^build means "the same task in my dependencies".

Versioning & publishing

ApproachFlow
Changesetspnpm add -Dw @changesets/cli, pnpm changeset init; per PR pnpm changeset; release with pnpm changeset version then pnpm changeset publish
pnpm 12 built-inpnpm change records an intent in .changeset/ (same format); pnpm version -r bumps versions and writes changelogs
Fixed or independentChangesets fixed / linked groups in .changeset/config.json
Private apps"private": true: versioned for changelogs, never published
pnpm change --bump minor --summary "Add Button size" \
  @acme/ui                    # non-interactive intent
pnpm change status            # pending intents
pnpm version -r               # apply bumps + CHANGELOG.md
pnpm -r publish --access public   # skips existing versions
pnpm publish --dry-run --filter @acme/ui

pnpm publish rewrites workspace: and catalog: to real ranges, refuses a dirty tree or a non-default branch (--no-git-checks to override) and supports --provenance from CI.

Patching dependencies

pnpm patch left-pad@1.3.0     # prints a temp dir to edit
# ...edit files in that dir...
pnpm patch-commit /tmp/abc123  # writes patches/*.patch +
                               # patchedDependencies entry
pnpm patch-remove left-pad@1.3.0

Prefer overrides to swap a version and packageExtensions to fix a broken package.json; patches are for code changes, and upstream PRs remain the real fix.

Docker & deploy

CommandUse
pnpm fetchdownload everything in pnpm-lock.yaml into the store: needs only the lockfile, so the layer caches until deps change
pnpm install --offline --frozen-lockfilelink from that store after copying the sources
pnpm --filter api deploy --prod out/copy one package plus a pruned, self-contained node_modules (workspace deps included) into out/
turbo prune api --dockeralternative: emit out/json (manifests) and out/full (sources) for the same layering
BuildKit cache--mount=type=cache,id=pnpm,target=/pnpm/store

pnpm 10 needed injectWorkspacePackages: true (or --legacy) for deploy; 12 copies workspace deps into the target either way, and pnpm's Docker guide still sets it. Full recipe below; image basics are in Dockerfile.

Bun workspaces comparison

pnpm 12Bun 1.4
Workspace listpackages: in pnpm-workspace.yaml"workspaces" in root package.json
Catalogscatalog / catalogs in the YAMLworkspaces.catalog / catalogs in package.json
Protocolsworkspace:, catalog:same
Layoutisolated symlinks + global storeisolated by default for new workspaces, hoisted optional
Lockfilepnpm-lock.yamlbun.lock (JSONC)
Build scriptsallowBuilds map, approve-buildstrustedDependencies, bun pm trust
Release-age gateminimumReleaseAge (minutes, default 1 day)[install] minimumReleaseAge (seconds, off)
Filters--filter with ..., ^, [since]--filter with ..., ^, {dir}; no [since]
Task ordering-r topological, tasks.dependsOn, pipeline cache--filter topological; no cache (add Turborepo)
Deploypnpm deploynone: bun build --compile or copy + bun install --production --filter
Migratepnpm import from npm/yarn locksbun install reads pnpm-lock.yaml

Bun installs and runs faster, and one tool also runs your code. pnpm wins on monorepo tooling: deploy, [since] filters, patches with review, supply-chain defaults. Mixing is common: pnpm for the workspace, bun to run scripts and tests.

Recipes

New monorepo from scratch

A pnpm workspace with shared tool versions in a catalog, ready for Turborepo.

mkdir acme && cd acme && git init
pnpm init                        # pins pnpm in package.json
pnpm pkg set private=true
mkdir -p apps packages tooling
cat > pnpm-workspace.yaml <<'EOF'
packages:
  - apps/*
  - packages/*
  - tooling/*
EOF
pnpm add -w -D typescript turbo --save-catalog
# root devDependencies now say "catalog:"
pnpm pkg set scripts.build="turbo run build" \
  scripts.test="turbo run test"
printf "node_modules\ndist\n.turbo\n" > .gitignore
pnpm install

Add an internal package

A source-only package that apps import by name, with no build step.

mkdir -p packages/utils/src
cat > packages/utils/package.json <<'EOF'
{
  "name": "@acme/utils",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" }
}
EOF
cat > packages/utils/src/index.ts <<'EOF'
export const slug = (s: string) =>
  s.toLowerCase().replace(/\W+/g, "-");
EOF
pnpm --filter @acme/api add "@acme/utils@workspace:*"
pnpm --filter @acme/api exec tsc --noEmit

Apps then import { slug } from "@acme/utils". Next.js needs transpilePackages: ["@acme/utils"]; Vite, Bun and Node 24 read the .ts directly.

Shared tsconfig package

One place for compiler options, versioned like any other package.

mkdir -p tooling/tsconfig
cat > tooling/tsconfig/package.json <<'EOF'
{ "name": "@acme/tsconfig", "private": true,
  "files": ["*.json"] }
EOF
# add base.json, node.json, react.json (see above)
pnpm --filter "./apps/*" --filter "./packages/*" \
  add -D "@acme/tsconfig@workspace:*"
tooling/tsconfig/react.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["es2024", "dom", "dom.iterable"],
    "jsx": "react-jsx",
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmit": true
  }
}

Run affected only

CI runs lint, tests and builds only for what a PR changed plus everything downstream.

git fetch origin main --depth=50
# pnpm filters
pnpm --filter "...[origin/main]" -r run lint
pnpm --filter "...[origin/main]" -r run test
# pnpm 12 pipeline (affected + cache)
pnpm pipeline --base origin/main
# Turborepo
pnpm turbo run build test --affected
# Nx
pnpm nx affected -t build test --base=origin/main

Prune for Docker

Build one app from the monorepo into a small image, with cache-friendly layers.

apps/api/Dockerfile
FROM ghcr.io/pnpm/pnpm:12 AS build
RUN pnpm runtime set node 24 -g
WORKDIR /repo
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --offline --frozen-lockfile
RUN pnpm --filter "@acme/api..." run build
RUN pnpm --filter @acme/api deploy --prod /out
 
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /out .
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Build from the repo root: docker build -f apps/api/Dockerfile .. Add a .dockerignore with node_modules, dist and .git.

References