pnpm Workspace Monorepo Setup: The Complete Guide
pnpm workspaces provide the fastest, most disk-efficient way to manage a JavaScript/TypeScript monorepo. Unlike npm and yarn, pnpm uses a content-addressable store and hard links, meaning shared dependencies are stored only once on disk regardless of how many packages use them. This guide walks through setting up a production-ready pnpm monorepo from scratch, including shared configs, internal packages, and CI/CD pipelines.
Why pnpm for Monorepos?
| Feature | npm workspaces | yarn workspaces | pnpm workspaces |
|---|---|---|---|
| Disk usage (500 deps) | ~800 MB | ~700 MB | ~400 MB |
| Install speed (cold) | ~25s | ~18s | ~12s |
| Install speed (warm) | ~10s | ~6s | ~2s |
| Strict node_modules | No (hoisted) | Partial (PnP) | Yes (isolated by default) |
| Phantom deps prevention | No | With PnP | Yes (strict by default) |
| Filtering commands | Basic (-w flag) | Good (foreach) | Excellent (--filter) |
| Content-addressable store | No | No | Yes |
| Side effects cache | No | No | Yes |
Initial Setup
# Install pnpm globally
npm install -g pnpm@latest
# Or use corepack (Node.js 16+)
corepack enable
corepack prepare pnpm@latest --activate
# Create project directory
mkdir my-monorepo && cd my-monorepo
# Initialize root package.json
pnpm init
# Create the workspace configuration
touch pnpm-workspace.yamlpnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- 'apps/*' # Applications (web, api, admin, etc.)
- 'packages/*' # Shared libraries and configs
- 'tools/*' # Build tools, scripts, generatorsRoot package.json
{
"name": "my-monorepo",
"private": true,
"packageManager": "pnpm@9.5.0",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"scripts": {
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"lint": "pnpm -r run lint",
"type-check": "pnpm -r run type-check",
"clean": "pnpm -r run clean && rm -rf node_modules",
"format": "prettier --write '**/*.{ts,tsx,js,json,md,yaml}' --ignore-path .gitignore",
"prepare": "husky"
},
"devDependencies": {
"prettier": "^3.3.0",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"typescript": "^5.5.0"
},
"lint-staged": {
"*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
"*.{json,md,yaml}": ["prettier --write"]
}
}.npmrc Configuration
# .npmrc β pnpm-specific settings
# Hoist only these packages to the root (needed for some tools)
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*eslint*
# Strict mode β prevent importing undeclared dependencies
strict-peer-dependencies=false
auto-install-peers=true
# Performance
prefer-frozen-lockfile=true
# Save exact versions
save-exact=trueProject Structure
my-monorepo/
βββ apps/
β βββ web/ # Next.js web application
β β βββ package.json # name: "@acme/web"
β β βββ tsconfig.json
β β βββ next.config.ts
β β βββ src/
β βββ api/ # Express/Fastify API server
β β βββ package.json # name: "@acme/api"
β β βββ tsconfig.json
β β βββ src/
β βββ admin/ # Admin dashboard
β βββ package.json # name: "@acme/admin"
β βββ src/
βββ packages/
β βββ ui/ # Shared React component library
β β βββ package.json # name: "@acme/ui"
β β βββ tsconfig.json
β β βββ src/
β β βββ components/
β β β βββ Button.tsx
β β β βββ Input.tsx
β β β βββ Modal.tsx
β β βββ index.ts
β βββ shared/ # Shared types and utilities
β β βββ package.json # name: "@acme/shared"
β β βββ src/
β β βββ types.ts
β β βββ validators.ts
β β βββ index.ts
β βββ config-eslint/ # Shared ESLint config
β β βββ package.json # name: "@acme/eslint-config"
β β βββ index.js
β βββ config-typescript/ # Shared TypeScript configs
β βββ package.json # name: "@acme/typescript-config"
β βββ base.json
β βββ nextjs.json
β βββ node.json
βββ pnpm-workspace.yaml
βββ pnpm-lock.yaml
βββ package.json
βββ .npmrc
βββ .gitignore
βββ turbo.json # Optional: Turborepo for task cachingCreating Internal Packages
Shared UI Library
// packages/ui/package.json
{
"name": "@acme/ui",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./components/*": {
"types": "./src/components/*.tsx",
"import": "./src/components/*.tsx"
}
},
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"dependencies": {
"clsx": "^2.1.0"
},
"devDependencies": {
"@acme/typescript-config": "workspace:*",
"@types/react": "^18.3.0",
"typescript": "^5.5.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
}// packages/ui/src/components/Button.tsx
import { type ButtonHTMLAttributes, forwardRef } from 'react';
import { clsx } from 'clsx';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, children, className, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
'bg-transparent hover:bg-gray-100': variant === 'ghost',
'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
'opacity-50 cursor-not-allowed': disabled || isLoading,
},
className
)}
{...props}
>
{isLoading ? 'Loading...' : children}
</button>
);
}
);
Button.displayName = 'Button';
// packages/ui/src/index.ts
export { Button, type ButtonProps } from './components/Button';
export { Input, type InputProps } from './components/Input';
export { Modal, type ModalProps } from './components/Modal';Shared TypeScript Config
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"exclude": ["node_modules", "dist", ".next"]
}
// packages/config-typescript/nextjs.json
{
"extends": "./base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
}
}Essential pnpm Commands
# Install all dependencies
pnpm install
# Add a dependency to a specific package
pnpm add zod --filter @acme/api
pnpm add -D vitest --filter @acme/shared
# Add a workspace dependency (internal package)
pnpm add @acme/shared --filter @acme/api --workspace
pnpm add @acme/ui --filter @acme/web --workspace
# Run a script in a specific package
pnpm --filter @acme/web dev
pnpm --filter @acme/api test
# Run across all packages
pnpm -r run build # Run build in all packages
pnpm -r --parallel run dev # Run dev in parallel
# Filter by directory
pnpm --filter ./apps/* run build
pnpm --filter ./packages/* run type-check
# Filter by dependency graph
pnpm --filter @acme/web... run build # web and all its deps
pnpm --filter ...@acme/shared run test # shared and all dependents
# Filter by changed files (great for CI)
pnpm --filter "...[origin/main]" run test # Test changed packages
# List all workspace packages
pnpm ls -r --depth -1
# Why is a package installed?
pnpm why react --filter @acme/web
# Update dependencies across workspace
pnpm -r update typescript
pnpm -r update --latest # Update to latest major versionsConsuming Internal Packages
// apps/web/package.json
{
"name": "@acme/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/shared": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@acme/eslint-config": "workspace:*",
"@acme/typescript-config": "workspace:*",
"@types/react": "^18.3.0",
"typescript": "^5.5.0"
}
}// apps/web/src/app/page.tsx
import { Button } from '@acme/ui';
import { formatDate, type User } from '@acme/shared';
export default function Home() {
return (
<main>
<h1>Welcome</h1>
<Button variant="primary" size="lg">
Get Started
</Button>
</main>
);
}CI/CD with pnpm
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm -r run type-check
- name: Lint
run: pnpm -r run lint
- name: Test changed packages
run: pnpm --filter "...[origin/main]" run test
- name: Build
run: pnpm -r run buildIntegrating with Turborepo
// turbo.json β optional but recommended for caching
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": { "outputs": [] },
"type-check": { "dependsOn": ["^build"], "outputs": [] }
}
}# Install Turborepo as a dev dependency
pnpm add -D turbo -w
# Use turbo instead of pnpm -r for task running
pnpm turbo build # Builds with caching and correct order
pnpm turbo test # Only re-tests changed packages
pnpm turbo dev # Starts all dev servers in parallelBest Practices
- Use workspace:* protocol β always reference internal packages with
workspace:* - Pin pnpm version β set
packageManagerin root package.json for reproducible installs - Enable strict mode β pnpm prevents phantom dependencies by default; do not disable this
- Use --frozen-lockfile in CI β never let CI modify the lockfile
- Scope dev dependencies β shared tools (prettier, husky) go in root; package-specific tools go in the package
- Add Turborepo for caching β pnpm handles dependencies, Turborepo handles task orchestration
- Use filter for CI β only build/test packages affected by the PR
Validate your package.json and turbo.json files with our JSON Formatter. For a broader look at monorepo tooling, read our Monorepo Guide 2026 covering Turborepo and Nx. For comparing pnpm with other package managers, see our npm vs yarn vs pnpm vs Bun comparison.