Monorepo Engineering in Practice: PNPM Workspaces, Turborepo Pipelines, and Scaling to 200 Packages
The Week We Merged 15 Repositories Into One
Monday: 15 independent repos, each with its own CI pipeline, dependency versions, lint rules, and release process. A cross-cutting shared type update meant 15 PRs, 15 CI runs, and 3 days of coordination. Friday: One Monorepo. One pnpm install. One turbo run build. Changes spanning 8 packages committed together, tested together, shipped together.
The migration took 6 weeks and was our best infrastructure investment that year. But we made mistakes — CI ran for 45 minutes before we added caching, node_modules consumed 12GB of disk before we configured PNPM correctly, and circular dependencies between packages took a week to untangle. Here's everything we learned.
Monorepo vs Multi-Repo: Decision Framework
| Factor | Multi-Repo | Monorepo |
|---|---|---|
| Cross-package changes | Multiple PRs, coordinated merges | Single PR, atomic change |
| Dependency versioning | Version drift across repos | Single source of truth (catalog) |
| CI speed | Fast per repo, slow cross-repo | Slow without cache, fast with cache |
| Local dev setup | Clone one repo, npm install |
Clone one repo, pnpm install |
| Code sharing | npm packages, version mismatch risk | Direct imports, always in sync |
| Access control | Per-repo permissions (simple) | CODEOWNERS approach (more complex) |
| Learning curve | None | Non-trivial for large teams |
When NOT to Use a Monorepo
- Teams don't touch each other's code: Go backend team and React frontend team never share types or logic — not worth it.
- Strict access control requirements: Some packages must be invisible to certain teams — Multi-Repo is simpler.
- Very large repos (>10GB): Git operations become slow. Use partial clones or split where necessary.
PNPM Workspace Deep Dive
PNPM is the de-facto standard for Monorepos in 2026. Its strict dependency resolution prevents the most common Monorepo mistake: accidentally importing packages not declared as dependencies.
Workspace Configuration
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
// package.json (root)
{
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.5.0"
},
"packageManager": "pnpm@9.1.0",
"engines": { "node": ">=20", "pnpm": ">=9" }
}
Workspace Protocol (workspace:*)
// packages/ui/package.json
{
"name": "@myorg/ui",
"dependencies": {
"@myorg/utils": "workspace:*", // Always use local latest
"@myorg/types": "workspace:^", // Compatible semver range
"react": "catalog:" // Pull shared version from catalog
}
}
The workspace:* protocol ensures packages always reference local versions during development. PNPM replaces it with the actual version when publishing.
PNPM Catalog for Unified Versions
# pnpm-workspace.yaml
catalog:
react: ^19.0.0
react-dom: ^19.0.0
next: ^15.0.0
typescript: ^5.5.0
zod: ^3.23.0
Update React in one place, 200 packages follow. Eliminates version drift.
Critical .npmrc Configuration
# .npmrc
shamefully-hoist=false # Strict mode, prevent accidental access
strict-peer-dependencies=true # Error on missing peers
node-linker=isolated # PNPM 9+ default: per-package symlinks
Package Structure: Organizing 200 Packages
monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── docs/ # Documentation site
│ └── admin/ # Admin dashboard
├── packages/
│ ├── ui/ # Shared React components
│ ├── utils/ # Pure utility functions
│ ├── types/ # Shared TypeScript types
│ ├── config-eslint/ # Shared ESLint config
│ ├── config-tailwind/ # Shared Tailwind preset
│ └── config-ts/ # Shared tsconfig base
├── tooling/
│ ├── scripts/ # Build/deploy scripts
│ └── generators/ # Code generators
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
Package Boundary Rules
// ✅ Correct: apps/web imports packages/ui
import { Button } from "@myorg/ui";
// ❌ Wrong: apps/web imports apps/admin
import { AdminPanel } from "@myorg/admin"; // Apps shouldn't depend on other apps
// ❌ Wrong: packages/ui imports apps/web
import { router } from "@myorg/web"; // Packages shouldn't depend on apps
Turborepo Pipeline Configuration
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "build/**"],
"inputs": ["src/**", "tsconfig.json", "package.json"]
},
"dev": {
"cache": false,
"persistent": true,
"dependsOn": ["^build"]
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", "eslint.config.mjs"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**", "test/**", "vitest.config.ts"]
},
"check-types": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"]
}
}
}
Turborepo Key Concepts
| Config | Purpose | Why It Matters |
|---|---|---|
dependsOn: ["^build"] |
Build dependencies first (^ = upstream) |
Ensures deps are built before consumers |
outputs |
Cacheable output directories | Skips re-execution if outputs exist |
inputs |
Files that affect cache validity | Changing non-input files doesn't invalidate cache |
cache: false |
Never cache this task | For long-running processes like dev servers |
persistent: true |
Long-running process | Dev server doesn't exit |
Remote Caching
npx turbo login
npx turbo link
Remote caching shares build results across CI runs and team members. The second developer building the same branch gets cache hits instantly.
Monorepo Dependency Management
Preventing Circular Dependencies
// packages/utils/src/format.ts
import { Button } from "@myorg/ui"; // ❌ Circular!
// packages/ui/src/Button.tsx
import { formatDate } from "@myorg/utils"; // This creates the cycle
Fix: Extract shared concern to a third package:
// packages/dates/src/format.ts — New package, depends on nothing from ui or utils
export function formatDate(date: Date): string { ... }
// packages/utils/src/format.ts — imports from dates
import { formatDate } from "@myorg/dates";
// packages/ui/src/Button.tsx — imports from dates
import { formatDate } from "@myorg/dates";
// Dependency graph: dates ← utils, dates ← ui (no cycle!)
Detect circular dependencies:
npx madge --circular --extensions ts,tsx packages/
Shared Configurations
TypeScript Project References
// tsconfig.json (root)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true
},
"references": [
{ "path": "packages/types" },
{ "path": "packages/utils" },
{ "path": "packages/ui" },
{ "path": "apps/web" }
]
}
Shared ESLint Config
// tooling/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/consistent-type-imports": "error",
},
}
);
// apps/web/eslint.config.mjs
import baseConfig from "@myorg/config-eslint/base.js";
export default [...baseConfig, { rules: { "no-console": "warn" } }];
Monorepo CI/CD
GitHub Actions + Turbo
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Turbo needs this for change detection
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm turbo lint check-types test build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
Change-Based Testing
# Run tasks only for packages affected by changes
pnpm turbo run build test lint --filter=[origin/main...HEAD]
Docker Build + Turbo Prune
FROM node:20-alpine AS pruner
WORKDIR /app
RUN npm install -g turbo@latest
COPY . .
RUN turbo prune --scope=@myorg/web --docker
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN corepack enable && pnpm install --frozen-lockfile --prod=false
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@myorg/web
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
turbo prune creates a minimal subset containing only the target app and its dependencies. No need to copy all 200 packages into Docker.
Migration: 15 Repos → 1 Monorepo
Phase 1: Audit Dependencies (Week 1)
Found 4 different versions of date-fns, 3 of lodash, 2 of zod. Unified all into catalog.
Phase 2: Create Skeleton (Week 2)
Established apps/, packages/, tooling/ directory structure.
Phase 3: Extract Shared Packages (Week 3-4)
Bottom-up extraction: types → utils → ui → config packages.
Phase 4: Migrate Apps (Week 5)
Migrate apps one by one, verify builds and deployments work correctly.
Phase 5: Optimize (Week 6)
- Enable Turborepo caching
- Configure remote cache
- Optimize CI pipeline
Performance Data
| Metric | Before (15 repos) | After (Monorepo) | Change |
|---|---|---|---|
pnpm install time (cold) |
15 × 45s = 11m15s | 1 × 28s = 28s | -95.8% |
| Full CI build | 15 × 3.2min = 48min | 3.8min (cached: 12s) | -92.1% |
| Cross-package change PRs | 6-8 | 1 | -85% |
| Disk usage (node_modules) | 15 × 850MB = 12.75GB | 2.1GB | -83.5% |
| New developer onboarding | ~2 hours | 8 minutes | -93.3% |
| Dependency version conflicts | 4-6 per sprint | 0 | -100% |
PNPM's content-addressable store and hard links to a single global store eliminate duplicate node_modules. Turborepo caching makes CI incremental builds near-instant.
Takeaway: Monorepo isn't about cramming everything into one folder — it's about making cross-package changes cheap and safe. Three pillars: PNPM workspaces for strict dependency isolation, Turborepo for build orchestration and caching, and shared configs for cross-package consistency. Start by extracting shared types and utilities. Add caching before adding people. Measure everything. The numbers don't lie: after migration, our team's cross-package change velocity improved by 5x, and the infrastructure cost increase was offset within the first month by eliminated CI duplication.
Online Tools
- JSON Formatter — Inspect package.json and turbo.json configs during Monorepo setup
- Hash Calculator — Verify cross-package file integrity for build caching
Try these browser-local tools — no sign-up required →