Skip to main content

JavaScript Monorepos 2026: Best Practices and Pitfalls

·PkgPulse Team
0

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

The monorepo pattern solves a specific problem: multiple apps or packages that need to share code, keep their dependencies synchronized, and make atomic changes. Before making the monorepo decision, it's worth being honest about whether you actually have that problem. The temptation to "set up a monorepo now before we need it" consistently produces overhead without benefit — teams that set up monorepos for single apps spend significant time on tooling configuration and learn the hard lesson that the value of a monorepo comes from the sharing it enables, not from the tooling itself.

The clearest signal that a monorepo is the right choice: you have a PR sitting in review that requires changes to two separate repositories to be merged in sync. If this has happened once, it will happen again. The "simultaneous PR" problem is the primary pain that monorepos solve. When a database schema change requires updating both the API server and the React frontend simultaneously, a monorepo makes that a single PR with a single review. In a polyrepo, it's two PRs that must be merged in order, with a window between merges where production can be in an inconsistent state.

The secondary signal: you copy-paste types, utilities, or configuration between projects. Shared TypeScript interfaces that live in two repositories will diverge. Shared ESLint configurations that are manually synchronized will drift. The moment you find yourself copy-pasting between repos, you're paying the polyrepo tax on code that should be shared.


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

Remote caching is the single most important operational improvement in monorepo CI. The mechanism: every task output (build artifacts, compiled TypeScript, test results) is hashed by the inputs that produced it (source files, dependencies, environment variables). On subsequent runs, Turborepo checks whether the hash of the inputs matches any cached output. If it does, the task is skipped and the cached output is restored. On a team CI system with shared remote cache, this means that PR #2 can reuse the build artifacts from PR #1 if none of the relevant source files changed.

The real-world impact compounds as the team grows. For a monorepo with 6 packages and 20 engineers making 50 PRs per day, the math is significant: without remote cache, 50 PRs × 6 minutes = 5 hours of CI time per day. With remote cache and affected detection, most PRs touch 1-2 packages, skip the others, and complete in 60-90 seconds. The throughput improvement is roughly 4x-8x on active teams.

The one-time setup cost is low: npx turbo login && npx turbo link connects Turborepo to Vercel's remote cache in under 5 minutes. The free tier covers most small-to-medium teams. For teams that can't use Vercel's hosted cache, turbo-remote-cache is an open-source alternative that can be self-hosted on S3 or any compatible object storage. Nx Cloud offers similar functionality for Nx-based monorepos. The infrastructure investment is minimal compared to the CI time savings.


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

The granularity pitfall is the most common mistake teams make when setting up monorepos. The intuition that "more modularity is better" — which is true at the application architecture level — doesn't transfer directly to monorepo package boundaries. Creating a separate package for every concern (a button/ package, a card/ package, a modal/ package) produces a monorepo that's expensive to maintain, difficult to refactor, and slow to build even with caching, because dependency graph traversal becomes expensive. The overhead of a package isn't zero: each package needs its own package.json, its own entry point, its own tsconfig.json extension, and its own build configuration. At 50 packages, that overhead compounds.

The rule that prevents over-granularity: only create a new package when there's a genuine reason to version or publish it independently. A UI component library that's shared across three apps is a good candidate for a packages/ui package. An individual Button component is not a good candidate for packages/button — it belongs in packages/ui. Extract to a new package when the component needs to be independently versioned, when it has different build requirements (a Node.js package vs a browser package), or when it needs a different set of peer dependencies. Start merged, split when there's a clear 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

Version mismatch problems in monorepos are most frequently caused by mismatched peer dependency declarations in internal packages. When packages/ui lists React as a dependency (which installs React inside packages/ui/node_modules) rather than a peerDependency (which expects the consumer to provide React), you can end up with two copies of React in the same application — one from the app, one from the UI package. Two React instances produce a cryptic runtime error ("cannot use hooks") that baffles teams who don't know the root cause. The fix is always to declare shared framework packages (React, Vue, Next.js) as peerDependencies in internal packages and devDependencies for development use. pnpm's dedupe-peer-dependents=true setting in .npmrc catches many of these cases automatically.

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

Migrating from Polyrepo to Monorepo: The Practical Path

Converting an existing polyrepo setup to a monorepo is one of the most technically involved migrations in JavaScript development. The practical path matters: the wrong migration approach creates weeks of git history confusion, broken CI, and difficult debugging. The right approach is incremental.

The recommended migration sequence: First, set up the monorepo skeleton in a new repository. Create the workspace structure (apps/, packages/, turbo.json), configure pnpm workspaces, and verify the setup with a trivial package. Don't move any existing code yet. This takes half a day and gives you a working monorepo foundation before introducing legacy complexity.

Second, identify the shared packages. Before moving any applications, extract the code that's already shared (or should be shared) between your existing repos — TypeScript types, utility functions, shared configuration. Create these as new packages in the monorepo's packages/ directory, written fresh or extracted from existing code. These shared packages are the primary reason you're doing the migration; they should exist and work before any apps are moved.

Third, move applications one at a time. Copy the first application into the apps/ directory, update its imports to use the shared packages, and verify it builds. This is the high-friction step — you'll hit TypeScript configuration mismatches, import resolution differences, and tool configuration issues that only appear when the code is in its new context. Resolve each issue as you encounter it rather than batching fixes. After the first application is migrated and CI is green, move the second.

Fourth, clean up git history (optional but recommended). The per-application git history is lost when you copy files to the new monorepo. If git history matters for your team (for git blame, for auditing), use git subtree or a monorepo migration script to preserve history. This adds significant time to the migration but is irreversible — you can't add the history later.

The common mistake: attempting a "big bang" migration that moves all repositories at once. The surface area of failure in a big bang migration is too large to debug effectively. The incremental approach keeps each step testable and allows the team to continue shipping while the migration happens in the background.


Nx vs Turborepo Comparison

FeatureTurborepoNx
Setup complexityLowMedium-High
Remote caching✅ Vercel cloud✅ Nx Cloud (paid)
Code generation✅ Generators
Affected detection--filter--affected
Framework plugins❌ Agnostic✅ React, Angular, etc.
CI time at scaleFastFaster (smarter graph)
Learning curveLowHigh
Best forStartups, mid-sizeEnterprise, 50+ packages

The choice between Nx and Turborepo is cleaner than the table suggests. Turborepo is the right default for teams that want to add build orchestration to an existing workspace setup with minimal new concepts. The configuration surface is small — turbo.json defines task dependencies and cache behavior, and the rest of your tooling (TypeScript, package manager, CI) remains unchanged. The Turborepo mental model is: "run tasks across packages in the right order, cache everything."

Nx is the right choice when you need the features that Turborepo doesn't have: code generators that scaffold new packages to a team standard, distributed task execution across multiple CI agents, and deep framework integrations that understand React, Angular, or Node.js project structures. At 50+ packages, Nx's more sophisticated dependency graph analysis and distributed execution produce meaningfully faster CI than Turborepo. At 5-20 packages, the overhead of Nx's generators and plugin system isn't justified.

The practical signal: if your team lead needs to run npx nx generate @nrwl/react:library new-feature to create new packages with the right structure, Nx's generators are worth the setup cost. If your team can create new packages manually from a copy-pasted template without issue, Turborepo's simpler model is sufficient. The migration path from Turborepo to Nx is well-documented if you outgrow Turborepo — start simple.


When Does a Monorepo Actually Make Sense?

The monorepo decision is reversible (you can split into separate repos), but converting is painful enough that it's worth getting right. Three threshold questions that determine whether a monorepo makes sense: First, are you actually sharing code? Sharing TypeScript types between frontend and backend is one of the primary monorepo value propositions. If your API returns a User type that your frontend displays, a monorepo with a shared @myapp/types package eliminates the duplication. Without real shared code, the monorepo adds tooling overhead with no architectural benefit. Second, do atomic commits matter? When a database schema change requires simultaneous updates to the API, the frontend, and the shared validation schema, a monorepo allows a single PR that changes all three. The reviewer sees the full picture; the change can't be merged partially. In a polyrepo setup, this requires coordinating three separate PRs with shared versioning — doable, but error-prone. Third, can your team absorb the CI complexity? Monorepos require affected-project detection to keep CI fast. Without it, CI runs the full build for every PR regardless of what changed. The --filter=...[origin/main] pattern (Turborepo) or --affected (Nx) handles this, but it requires CI configuration knowledge. Teams that haven't done this before spend 1-2 days getting it right.

The honest threshold: a 2-person startup with one Next.js app probably doesn't need a monorepo yet. A 5-person team with two apps and a shared component library is exactly at the inflection point where the benefits start outweighing the overhead. The decision gets clearer as teams grow: at 10+ engineers sharing code across multiple apps, the coordination overhead of polyrepos (duplicate types, unsynchronized dependency updates, multi-repo PRs) consistently exceeds the overhead of monorepo tooling. At this scale, most engineering leads who've worked in both models prefer monorepos not for theoretical reasons but because they've experienced both and found monorepos produce fewer inter-team coordination incidents in practice.


Getting Affected Detection Right in CI

Affected-project detection is the key to keeping monorepo CI fast as the codebase grows. Without it, CI time grows linearly with the number of packages. With it, CI time grows only with the scope of each change. The implementation with Turborepo is straightforward — turbo build --filter=...[origin/main] builds only packages that have changed since the PR branched from main. The ... means "and all packages that depend on the changed packages." Turborepo's dependency graph ensures that if @myapp/database changed, @myapp/api (which depends on it) also rebuilds. The remote cache multiplies this: Turborepo's Vercel Remote Cache stores build artifacts by content hash. If @myapp/ui hasn't changed in 5 PRs, it hits the cache on all 5 of those PRs — the build doesn't run, it just downloads the cached result in milliseconds.

Real-world CI time with this setup: a monorepo with 8 packages running on GitHub Actions, with remote cache enabled and affected detection configured, typically achieves 90% of PRs completing in under 2 minutes. Without remote cache and affected detection, the same monorepo takes 6-8 minutes per PR. The one edge case: changes to shared configuration packages (like @myapp/config with TypeScript config) trigger full rebuilds across all packages that use the config. Keep shared configs stable and minimal — one tsconfig change that affects 8 packages is 8 rebuilds.


pnpm vs npm vs yarn for Workspace Management

The package manager choice for monorepos matters more than for single-repo projects because of how workspaces handle shared dependencies. pnpm is the dominant choice for monorepos in 2026 for three reasons: First, pnpm's symlink-based node_modules approach (using a content-addressable store) reduces disk usage by 40-70% in monorepos compared to npm or Yarn, where each workspace installs its own copy of shared packages. In a monorepo with 10 packages all using React 18, pnpm installs React once globally; npm installs it 10 times. Second, pnpm's workspace:* protocol makes it clear which packages are internal (monorepo packages) vs external (npm packages). "@myapp/ui": "workspace:*" is unambiguous; npm's equivalent requires managing a separate symlink step. Third, pnpm is the fastest of the three for install performance in monorepos — not as fast as Bun (bun install), but faster than npm and competitive with Yarn 4.

The case for Yarn 4 (formerly Yarn Berry): Plug'n'Play mode eliminates the node_modules directory entirely, using a .pnp.cjs map instead. This is faster than any node_modules approach for installs. The tradeoff: not all tools support Plug'n'Play, requiring .pnpify wrappers for some CLIs. Teams that have set up Yarn 4 PnP correctly report excellent DX; teams new to it frequently hit compatibility issues with IDE plugins and CLIs. The practical recommendation for most teams: use pnpm workspaces. It's well-documented, widely supported, and delivers 90% of the benefits of Yarn 4 PnP with 10% of the setup friction.

The one scenario where bun install in a monorepo is worth evaluating: if your CI install step is a bottleneck (taking more than 60 seconds) and you're on a time-sensitive deployment pipeline. bun install reads pnpm workspace configurations and works as a drop-in replacement in most cases. The install speed improvement (10-15x over npm, 3-5x over pnpm) can be material in CI-constrained environments. The compatibility risk is the same as bun install generally: if any packages use install scripts that depend on npm-specific behavior, they may fail. Audit before switching.


Compare monorepo tooling package health on PkgPulse.

See also: AVA vs Jest and Dependency Management for Monorepos 2026, Best Monorepo Tools in 2026: Turborepo vs Nx vs Moon.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.