← Back to Blog
Next.js2026-05-12Β·69 min read

pnpm workspaces in a Next.js 16 monorepo: what the benchmark didn't measure and almost broke my CI

By Juan Torchia

Taming pnpm's Strict Isolation: Deterministic CI Builds for Next.js 16 Monorepos

Current Situation Analysis

Modern monorepo tooling has shifted from aggressive dependency hoisting to strict isolation. pnpm pioneered this shift by storing packages in a global content-addressable store and symlinking them into workspace-specific node_modules directories. The architectural benefit is clear: phantom dependencies are eliminated, disk usage drops, and install times improve. However, this isolation model introduces a subtle but critical failure mode when paired with Next.js 16's App Router and Turbopack in continuous integration environments.

The industry pain point is not install speed. It is graph resolution consistency under partial cache restoration. Most benchmarking methodologies measure pnpm install execution time on a clean or warm local machine. They completely ignore what happens during the Next.js compilation phase when Turbopack constructs its module graph. In CI, cache restoration is rarely perfect. A lockfile update in one workspace, combined with a partially restored pnpm store, creates a state where multiple instances of the same peer dependency coexist in the store. Turbopack's module resolution algorithm walks the node_modules tree upward, and when it encounters divergent versions of a framework runtime like React, it resolves chunks non-deterministically.

This problem is systematically overlooked because it only manifests during the build step, not the install step. Developers assume that if pnpm install succeeds, the dependency graph is valid. In reality, pnpm's strict isolation allows semver-compatible but distinct versions to occupy separate store entries. When Next.js 16 compiles server and client chunks, Turback's resolver may bind a shared component to one React instance while the host application binds to another. The result is not a missing module error during installation, but a silent graph corruption that surfaces as Cannot find module during compilation or Invalid hook call at runtime.

Data from production monorepos confirms the scale of the issue. In a workspace topology containing six shared packages and three applications, divergent peer dependency ranges can spawn up to eleven duplicate instances of core runtimes. Each duplicate inflates the store, fragments the resolution cache, and forces Turbopack to re-evaluate chunk boundaries on every CI run. Teams report CI build durations swinging from 4–6 minutes on stable cache hits to 12–18 minutes when the resolver encounters inconsistent graph states. The bottleneck is never disk I/O or package download speed. It is the computational overhead of resolving a fragmented dependency graph under Turbopack's strict module boundary enforcement.

WOW Moment: Key Findings

The critical insight is that install-time metrics are decoupled from build-time determinism. Optimizing for pnpm install speed without addressing peer dependency alignment guarantees intermittent CI failures. The following comparison demonstrates how different configuration strategies impact monorepo stability:

Approach Build Determinism Cache Efficiency Isolation Integrity Avg CI Duration
Default Strict Isolation Low (non-deterministic with partial cache) Moderate (store fragmentation) High (per-workspace boundaries) 12–18 min
Nuclear Hoist (shamefully-hoist=true) High (flat resolution) High (single root node_modules) Low (phantom dependencies) 5–7 min
Targeted Overrides + Selective Hoist High (single instance guarantee) High (predictable store layout) High (preserved workspace boundaries) 4–6 min

This finding matters because it shifts the optimization target from package installation to graph resolution. By enforcing a single instance of framework runtimes while preserving pnpm's strict isolation for utility packages, teams achieve reproducible builds without sacrificing the architectural benefits of workspace boundaries. The configuration eliminates Turbopack's resolution race conditions, stabilizes cache hit rates, and reduces CI duration by removing redundant graph traversal overhead.

Core Solution

The resolution strategy requires three coordinated changes: peer dependency alignment, selective hoisting for framework runtimes, and cache key fingerprinting. Each step addresses a specific layer of the failure mode.

Step 1: Enforce Single Runtime Instances via Overrides

The root cause of graph fragmentation is divergent semver ranges across workspaces. pnpm respects pnpm.overrides in the root package.json, forcing all workspaces to resolve to a single version regardless of their declared ranges.

{
  "name": "@acme/monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.0",
  "pnpm": {
    "overrides": {
      "react": "18.3.1",
      "react-dom": "18.3.1",
      "next": "16.0.4"
    }
  }
}

Rationale: Overrides operate at the resolver level before installation. Unlike hoisting, which moves files into a flat directory, overrides instruct pnpm to treat all version requests for a specified package as equivalent to the pinned version. This guarantees a single store entry and eliminates Turbopack's resolution ambiguity.

Step 2: Configure Selective Hoisting for Framework Runtimes

Turbopack expects framework runtimes to be accessible from the root node_modules to maintain consistent chunk boundaries. While overrides prevent duplication, explicitly hoisting framework packages ensures Turbopack's resolver does not traverse workspace-specific node_modules directories during compilation.

# .npmrc at monorepo root
public-hoist-pattern[]=react
public-hoist-pattern[]=react-dom
public-hoist-pattern[]=next
public-hoist-pattern[]=@types/react*
public-hoist-pattern[]=@types/react-dom*

Rationale: public-hoist-pattern creates a controlled exception to pnpm's strict isolation. Only packages matching the glob patterns are symlinked to the root node_modules. All other dependencies remain strictly isolated per workspace. This balances Turbopack's resolution expectations with pnpm's architectural benefits.

Step 3: Implement Lockfile-Fingerprinted CI Caching

Partial cache restoration is the primary trigger for non-deterministic builds. The cache key must be derived from the lockfile hash, not a static string or branch name.

# .github/workflows/build.yml
name: Monorepo CI
on: [push, pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Initialize pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9

      - name: Configure Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - name: Restore dependency store
        uses: actions/cache@v4
        id: pnpm-cache
        with:
          path: ~/.local/share/pnpm/store
          key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
          restore-keys: |
            pnpm-store-

      - name: Install workspace dependencies
        run: pnpm install --frozen-lockfile

      - name: Compile Next.js applications
        run: pnpm --filter @acme/portal build

Rationale: --frozen-lockfile prevents silent lockfile mutations during CI. If the restored store does not match the lockfile, the command fails immediately rather than producing a corrupted graph. The hashFiles('pnpm-lock.yaml') key ensures cache invalidation occurs only when the dependency graph actually changes, eliminating partial restoration race conditions.

Pitfall Guide

1. Assuming Development Parity with Production Builds

Explanation: Next.js development mode uses file-system watchers and lazy compilation, bypassing Turbopack's full module graph construction. Errors like Invalid hook call or missing workspace imports rarely surface locally. Fix: Always validate builds in a CI environment or run next build locally with NODE_ENV=production before merging.

2. Caching Without Lockfile Fingerprinting

Explanation: Using static cache keys or branch-based keys causes CI to restore stale store entries when the lockfile changes. pnpm's integrity checks fail, or worse, succeed with mismatched versions. Fix: Derive cache keys exclusively from pnpm-lock.yaml hashes. Use restore-keys as a fallback, never as the primary key.

3. Overusing shamefully-hoist=true

Explanation: This flag flattens all dependencies into the root node_modules, mimicking npm/yarn behavior. It masks resolution errors but introduces phantom dependencies that work in CI but fail in production when workspace boundaries are enforced. Fix: Reserve shamefully-hoist=true for temporary diagnostics. Apply targeted overrides and selective hoisting for production configurations.

4. Ignoring Peer Dependency Range Alignment

Explanation: Declaring "react": "^18.3.0" in one workspace and "react": "^18.3.1" in another allows pnpm to store both versions. Semver compatibility does not guarantee instance uniqueness. Fix: Standardize all peer dependency ranges across workspaces. Use pnpm.overrides to enforce the final resolved version.

5. Misinterpreting Invalid Hook Call Errors

Explanation: This runtime error indicates multiple React instances in the bundle. Developers often blame component architecture or custom hooks, when the actual cause is graph fragmentation. Fix: Run pnpm why react --recursive to verify instance count. If multiple versions appear, apply overrides and verify hoisting patterns.

6. Skipping --frozen-lockfile in CI

Explanation: Without this flag, pnpm silently updates the lockfile when the restored store is incomplete. The next CI run fails with integrity errors, creating a cascading cache corruption loop. Fix: Always pass --frozen-lockfile in CI. Commit lockfile changes explicitly through pull requests.

7. Relying on Default CI Restore Keys

Explanation: GitHub Actions and Railway default to branch-based or timestamp-based cache keys. These do not account for lockfile mutations, leading to partial cache hits that corrupt the resolution graph. Fix: Implement explicit cache key generation using lockfile hashes. Document cache invalidation policies in the repository.

Production Bundle

Action Checklist

  • Audit duplicate instances: Run pnpm why react --recursive and verify single-instance output
  • Standardize peer ranges: Align all workspace package.json files to identical semver ranges for framework runtimes
  • Apply root overrides: Configure pnpm.overrides in the root package.json to pin framework versions
  • Configure selective hoisting: Add public-hoist-pattern entries to .npmrc for React, Next.js, and TypeScript types
  • Fingerprint CI cache: Replace static cache keys with hashFiles('pnpm-lock.yaml')
  • Enforce frozen lockfile: Append --frozen-lockfile to all CI install commands
  • Validate build parity: Run next build in CI before merging any workspace dependency updates

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Small monorepo (<3 workspaces) shamefully-hoist=true Simplifies resolution; isolation benefits are marginal at this scale Low CI maintenance, higher risk of phantom deps
Medium monorepo (3–8 workspaces) pnpm.overrides + public-hoist-pattern Guarantees single instance while preserving workspace boundaries Moderate setup, high CI stability
Large monorepo (>8 workspaces) pnpm.overrides + strict isolation + automated deduplication scripts Prevents graph fragmentation at scale; enforces dependency contracts Higher initial configuration, lowest long-term CI cost
Legacy migration to pnpm Gradual override application + phased hoisting Avoids breaking existing build pipelines during transition Medium migration effort, immediate stability gains

Configuration Template

# .npmrc
public-hoist-pattern[]=react
public-hoist-pattern[]=react-dom
public-hoist-pattern[]=next
public-hoist-pattern[]=@types/react*
public-hoist-pattern[]=@types/react-dom*
// package.json (root)
{
  "name": "@acme/monorepo",
  "private": true,
  "packageManager": "pnpm@9.15.0",
  "pnpm": {
    "overrides": {
      "react": "18.3.1",
      "react-dom": "18.3.1",
      "next": "16.0.4",
      "typescript": "5.7.2"
    }
  }
}
# .github/workflows/ci.yml (cache fragment)
- name: Restore pnpm store
  uses: actions/cache@v4
  with:
    path: ~/.local/share/pnpm/store
    key: pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-store-

- name: Install dependencies
  run: pnpm install --frozen-lockfile

Quick Start Guide

  1. Initialize workspace structure: Create pnpm-workspace.yaml with packages: ['apps/*', 'packages/*'] and verify workspace detection with pnpm list -r.
  2. Apply overrides: Add pnpm.overrides to the root package.json pinning React, React DOM, and Next.js to exact versions.
  3. Configure selective hoisting: Add public-hoist-pattern entries to .npmrc for framework runtimes and TypeScript definitions.
  4. Update CI workflow: Replace cache keys with lockfile hashes and append --frozen-lockfile to the install step.
  5. Validate resolution: Run pnpm install, then execute next build in the target application. Verify zero duplicate instances and consistent build duration across multiple CI runs.