JavaScript Monorepos in 2026: Best Practices and Pitfalls
·PkgPulse Team
TL;DR
Turborepo + pnpm for most monorepos; Nx for enterprise-scale. Turborepo (~2M weekly downloads) became the default monorepo build orchestrator in 2026 — simple config, fast caching, works with any package manager. pnpm workspaces (~5M downloads) is the standard package manager for monorepos. Nx (~2M) is the right choice when you need code generation, affected-project detection at enterprise scale, or deep framework integrations. Don't use Lerna in 2026 — it's been superseded.
Key Takeaways
- Turborepo: ~2M weekly downloads — build orchestration, caching, task dependency graph
- Nx: ~2M downloads — enterprise-scale, code generators, affected project detection
- pnpm workspaces: ~5M downloads — workspace management, hoisting control, symlinks
- The key win — remote caching (Turborepo/Nx) → CI runs that hit cache complete in seconds
- Common mistake — too granular packages; start with 3-5 packages, not 50
When to Use a Monorepo
# Good reasons for a monorepo:
✅ Multiple apps sharing code (web + api + mobile using same types)
✅ UI component library shared across multiple frontends
✅ Multiple packages published to npm from one repo
✅ Team wants atomic commits (UI + API change in one PR)
✅ Shared tooling configuration (ESLint, TypeScript, Prettier)
# Bad reasons (monorepo overhead not worth it):
❌ Single app with no sharing
❌ "It might grow into needing shared code" (optimize when you have the problem)
❌ Team has no experience with monorepos (significant learning curve)
❌ Shared CI infrastructure isn't set up to handle affected-project detection
# The monorepo threshold:
# If you have 2+ apps sharing code → consider monorepo
# If you have 5+ apps or 3+ published packages → monorepo is likely worth it
Setup: Turborepo + pnpm
# Bootstrap a new Turborepo monorepo
npx create-turbo@latest
# Or add Turborepo to existing pnpm workspace:
pnpm add turbo --workspace-root
// pnpm-workspace.yaml — workspace definition
packages:
- "apps/*" # All subdirectories in apps/
- "packages/*" # All subdirectories in packages/
# Directory structure:
monorepo/
├── apps/
│ ├── web/ # Next.js web app
│ ├── api/ # Hono API server
│ └── admin/ # Internal admin Next.js app
├── packages/
│ ├── ui/ # Shared React components
│ ├── database/ # Drizzle schema + db client
│ ├── config/ # Shared TS/ESLint/Biome config
│ └── utils/ # Shared TypeScript utilities
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
// turbo.json — build pipeline configuration
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // ^ = run this task in deps first
"outputs": [".next/**", "dist/**", "build/**"], // Cache these
"cache": true
},
"dev": {
"cache": false, // Never cache dev servers
"persistent": true // Keep running (don't exit)
},
"lint": {
"dependsOn": [], // No deps — run all in parallel
"outputs": [] // No cacheable output
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"type-check": {
"dependsOn": ["^build"],
"outputs": []
}
},
"remoteCache": {
"enabled": true // Vercel remote cache (free tier available)
}
}
# Running tasks across all packages
turbo build # Build all packages (in dependency order)
turbo build lint # Build then lint, in parallel where possible
turbo dev # Start all dev servers
turbo test --filter=@myapp/web # Only web app
turbo build --filter=...@myapp/web # Web app and its deps
turbo build --since=main # Only changed packages since main
Remote Caching (The Key CI Win)
# Without remote cache:
# PR #1: npm install → build → test → 6 minutes
# PR #2: npm install → build → test → 6 minutes
# (even if only 1 package changed)
# With Turborepo remote cache:
# PR #1: npm install → build → test → 6 minutes (cache primes)
# PR #2: npm install → cache hit (web) → build api (only change) → 45 seconds
# Real CI time savings at scale:
# Before remote cache: 8 min per PR
# After remote cache: 1.5 min per PR (80%+ cache hit rate)
# Savings: ~6.5 min per PR × 50 PRs/day = 5+ hours of CI time/day
# Setup Vercel Remote Cache (free):
npx turbo login
npx turbo link # Links to Vercel team for cache sharing
Package Configuration
// packages/ui/package.json — shared component library
{
"name": "@myapp/ui",
"version": "0.0.0", // "Private" packages often use 0.0.0
"private": true, // Not published to npm
"main": "./src/index.ts", // Direct TypeScript source (no build needed!)
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"react": "^18.2.0", // devDependency: consumers provide react
"typescript": "^5.3.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
}
// Note: For monorepo-internal packages, you can skip the build step
// apps/web imports @myapp/ui source TypeScript directly
// tsconfig paths + TypeScript resolves it
// apps/web/package.json — Next.js app
{
"name": "@myapp/web",
"private": true,
"dependencies": {
"@myapp/ui": "workspace:*", // pnpm workspace protocol
"@myapp/database": "workspace:*",
"@myapp/utils": "workspace:*",
"next": "15.0.0",
"react": "18.3.0"
}
}
// packages/config/package.json — shared configs
{
"name": "@myapp/config",
"private": true,
"main": "index.js",
"files": ["biome.json", "tsconfig.json", "tsconfig.base.json"]
}
// packages/config/tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"skipLibCheck": true
}
}
// apps/web/tsconfig.json
{
"extends": "@myapp/config/tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
}
}
Common Pitfalls
Pitfall 1: Too Many Packages Too Early
# Bad: 25 packages for a 3-developer team
packages/
├── api-client/
├── auth/
├── button/ ← Should be in ui/
├── card/ ← Should be in ui/
├── constants/
├── database/
├── email/
├── env/
├── errors/
├── formatters/ ← utils/
├── hooks/ ← Should be in ui/ or web/
├── icons/
├── input/ ← Should be in ui/
├── logger/
├── modal/ ← Should be in ui/
├── permissions/
├── types/
├── utils/
└── validators/ ← Part of utils or each consumer
# Better: 4-5 packages that grow naturally
packages/
├── ui/ # All shared React components
├── database/ # Schema + db client
├── utils/ # Shared TypeScript utilities
└── config/ # Shared configs (TS, ESLint, etc.)
# Rule: start with 3-5 packages; extract to new package when there's genuine need
Pitfall 2: Version Mismatch Hell
# Problem: apps/web uses React 18.2; packages/ui uses React 18.0
# Solution: declare React as peerDependency in packages, devDependency
# pnpm-workspace.yaml — hoist React to root
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
# .npmrc — prevent duplicate React instances
dedupe-peer-dependents=true
Pitfall 3: Forgetting --filter in CI
# Bad CI: always build everything
# .github/workflows/ci.yml
- run: turbo build # Runs ALL packages every PR
# Good CI: only affected packages + cache
- run: turbo build --filter=...[origin/main]
# Only builds packages changed since main branched
# Combined with remote cache: usually finishes in seconds
Nx vs Turborepo Comparison
| Feature | Turborepo | Nx |
|---|---|---|
| Setup complexity | Low | Medium-High |
| Remote caching | ✅ Vercel cloud | ✅ Nx Cloud (paid) |
| Code generation | ❌ | ✅ Generators |
| Affected detection | ✅ --filter | ✅ --affected |
| Framework plugins | ❌ Agnostic | ✅ React, Angular, etc. |
| CI time at scale | Fast | Faster (smarter graph) |
| Learning curve | Low | High |
| Best for | Startups, mid-size | Enterprise, 50+ packages |
Compare monorepo tooling package health on PkgPulse.
See the live comparison
View turborepo vs. nx on PkgPulse →