Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Monorepo Architecture & Orchestration

Modern JavaScript and TypeScript ecosystems demand deterministic builds, strict dependency isolation, and scalable task orchestration. A monorepo centralizes shared code, tooling, and deployment pipelines behind a single version-controlled history, enabling atomic commits across many packages — but it also concentrates resolution, build-ordering, and caching complexity into one place. This guide establishes production-grade standards for topology design, dependency classification, lockfile integrity, ESM/CJS packaging, and task orchestration across npm, pnpm, Yarn, Turborepo, and Nx.

The payoff of getting this right is concrete: a well-architected monorepo turns a 20-minute cold CI run into a 90-second cache-hit run, blocks phantom dependencies before they reach production, and lets a single pnpm -r build rebuild only the packages that actually changed.

Monorepo task graph with caching A change in a shared package fans out through a topological task graph; unchanged packages are restored from the remote cache instead of rebuilding. @repo/core changed source @repo/ui rebuild @repo/api rebuild @repo/docs cache hit web app rebuild remote cache restore
A change in @repo/core fans out along the topological graph; only its dependents rebuild while unchanged packages are restored from the remote cache.

How the topics in this guide fit together

This guide is organized around the decisions a platform team makes when scaling a monorepo. At the foundation, Cross-Package Dependency Management governs how internal packages reference each other through the workspace: protocol, and how you keep that graph acyclic — the failure mode covered in depth by Debugging Circular Dependencies in Monorepos. The physical layer underneath it — how node_modules is materialized — is the subject of Workspace Symlinks vs Hard Links, with the most common breakage documented in Fixing Broken Symlinks in pnpm node_modules.

On top of that foundation sit the task runners. Choosing a Monorepo Task Runner compares the options before you commit; once committed, Turborepo Pipeline Configuration and Nx Workspace Architecture cover the two dominant engines, with their tradeoffs quantified in Nx vs Turborepo Performance Benchmarks and Nx's incremental targeting detailed in Configuring Nx Affected Commands in CI.

Scoping work down to what changed is handled by pnpm Workspace Filtering and its hands-on companion Using pnpm --filter for Targeted Builds. Finally, the speed multiplier across all of it is Remote Caching Setup, expanded by Optimizing Turborepo Remote Cache for CI, Fixing Turborepo Remote Cache Misses, and Self-Hosting a Turborepo Remote Cache. When the packages in this monorepo are ready to ship, the workflows in Package Publishing & Release Engineering take over.

1. Topology, package resolution, and the packageManager contract

Monorepos consolidate multiple packages into a single repository, reducing cross-repo synchronization overhead and enabling atomic commits that span many packages at once. Polyrepos offer stricter physical boundaries but suffer from dependency drift, duplicated CI configurations, and fragmented versioning. For platform teams and library authors, the monorepo model wins decisively when paired with strict resolution rules and explicit package boundaries — and loses badly without them, because every weakness compounds across packages.

Package managers materialize internal workspace resolution differently. npm defaults to a flat, hoisted node_modules tree, which risks phantom dependencies — modules that import successfully in development because a transitive dependency happened to be hoisted, then fail in production. Yarn Berry introduced Plug'n'Play (PnP) to eliminate node_modules entirely via a .pnp.cjs resolver, though it also ships a node-modules linker for conventional layouts. pnpm enforces strict isolation through a content-addressable store plus a symlinked node_modules, guaranteeing that a package can only resolve dependencies it explicitly declared.

To guarantee deterministic resolution across every environment, pin the package manager version with the packageManager field in the root package.json. Corepack (bundled with Node.js since 16.13) reads this field and transparently proxies to the correct binary, so a developer who runs pnpm install with the wrong version installed gets an error instead of a silently different lockfile.

{
  "packageManager": "pnpm@10.4.1",
  "engines": {
    "node": ">=18.0.0"
  },
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

The only-allow preinstall guard is the second half of the contract: it rejects npm install or yarn invocations that would otherwise produce a competing lockfile. Pin both halves — the binary version and the allowed manager — and toolchain drift across developer machines and CI runners disappears.

Choosing a manager is itself a topology decision. npm's flat hoist is the path of least resistance and the most permissive, which is exactly why it leaks phantom dependencies most readily; it suits small workspaces where every package is published together and strictness buys little. pnpm's isolated linker is the safest default for a growing monorepo because it makes phantom dependencies structurally impossible — a package that forgot to declare lodash simply cannot resolve it. Yarn Berry's PnP is the strictest of all and the fastest to install, at the cost of tooling that must understand the .pnp.cjs resolver; reach for it when install time on CI is your dominant pain and your toolchain is PnP-aware. Whichever you pick, encode it once in packageManager and let Corepack enforce it for everyone.

# .npmrc (root) — pnpm strictness defaults
node-linker=isolated
strict-peer-dependencies=true
auto-install-peers=true

Internal workspace packages must resolve predictably regardless of which manager you choose. The mechanics of that resolution — symlinks for a single canonical copy versus hard links into a shared store — are covered in Workspace Symlinks vs Hard Links. The default for every modern manager is a symlink, because it preserves clear package boundaries, supports concurrent builds, and reflects source edits immediately without a reinstall. When those symlinks break — usually after a partial install, an interrupted pnpm install, or a manual node_modules edit — the resolver throws Cannot find module for a package that visibly exists on disk; that exact failure and its recovery are documented in Fixing Broken Symlinks in pnpm node_modules.

A predictable layout also depends on a deliberate workspace definition. Keep the package globs narrow and explicit in pnpm-workspace.yaml (or the workspaces array for npm/Yarn), exclude test fixtures so they are never linked, and group packages by role — packages/* for libraries, apps/* for deployables — so the dependency direction is obvious at a glance. The full layout patterns, including shared tooling configuration, are covered in Workspace Configuration Deep Dive and its companions Setting Up npm Workspaces for Small Teams and Setting Up Shared ESLint Configs in Workspaces. Teams migrating off an older toolchain should start with Migrating from Yarn 1 to pnpm Workspaces.

2. Dependency classification across the workspace

Inside a monorepo, dependency classification is not cosmetic — it determines what gets installed, what gets bundled, and what a consumer of a published package is forced to supply. Three buckets matter, and the boundaries between them follow the same rules covered for single packages in Understanding package.json Fields.

Field Installed for consumers? Use it for
dependencies Yes Runtime imports the package needs at execution time
devDependencies No Build tools, test runners, type definitions, linters
peerDependencies No (consumer supplies) Shared singletons like react or a plugin host

The line between peer and dev dependencies is the one teams get wrong most often; the decision tree in When to Use peerDependencies vs devDependencies resolves the ambiguity. For internal packages, never use a bare semver string. Use the workspace: protocol so the manager symlinks the local source instead of fetching a stale published copy from the registry:

{
  "dependencies": {
    "@repo/utils": "workspace:*"
  },
  "peerDependencies": {
    "react": ">=18"
  }
}

At install time the manager rewrites workspace:* to the actual version when the package is published, so the same manifest works locally and on the registry. The full mechanics of internal references — including when to use workspace:^ for independently versioned private packages — are the subject of Cross-Package Dependency Management.

To patch a transitive CVE across every package at once without cascading major bumps, declare a root-level override:

{
  "pnpm": {
    "overrides": {
      "lodash@<4.17.21": ">=4.17.21",
      "semver": "7.5.4"
    }
  }
}

The equivalents are resolutions for Yarn and overrides for npm. Use them only for security patches and critical bug fixes — verify each one with pnpm why <package> first, because an override that breaks a peer range will fail silently until runtime.

A subtle but expensive monorepo failure is two copies of the same singleton dependency — most famously two React versions, which produces the "Invalid hook call" error because hooks compare identity across module instances. This happens when one package pins react as a direct dependency and another receives it transitively at a different version. The fix is to declare such singletons as peerDependencies in every internal package and pin the single allowed version once at the root; the diagnosis and deduplication procedure is detailed in Deduplicating Duplicate React Versions. The broader resolution algorithm — how ranges become a single resolved tree — is explained in Dependency Resolution Explained, and the specific ERESOLVE failure that surfaces when peer ranges conflict is handled in Fixing npm ERESOLVE Peer Dependency Conflicts.

3. Lockfile integrity and CI enforcement

A monorepo lockfile is the single source of truth for the entire dependency graph, which makes its integrity non-negotiable. The strategies for keeping it clean — committing it, regenerating it deterministically, and resolving the merge conflicts it inevitably produces — are covered in Lockfile Management Strategies, with the gnarliest case handled by Fixing pnpm-lock.yaml Merge Conflicts.

The cardinal CI rule: never run a mutating install in a pipeline. pnpm install or npm install will silently rewrite the lockfile to satisfy whatever package.json says, which defeats the entire point of pinning. Use the frozen variant so the job fails loudly when the lockfile and manifests disagree:

steps:
  - name: Install dependencies
    run: pnpm install --frozen-lockfile
  - name: Verify workspace integrity
    run: pnpm list --recursive --depth=0

The npm equivalent is npm ci; Yarn Berry uses yarn install --immutable. Each one refuses to mutate the lockfile and exits non-zero on drift, which converts a class of "works on my machine" bugs into a failed check on the pull request that introduced them.

A monorepo lockfile is also the highest-value merge-conflict generator in the repository, because almost every pull request that adds or bumps a dependency touches it. The wrong reflex — hand-editing the conflict markers — corrupts the integrity hashes and produces an install that no longer matches any real resolution. The correct procedure is to accept either side, then regenerate deterministically with a single install; the pnpm-specific recipe is in Fixing pnpm-lock.yaml Merge Conflicts. Configure the lockfile as a binary-merge path in .gitattributes so Git stops attempting line-level merges that only create more corruption:

# .gitattributes
pnpm-lock.yaml merge=binary linguist-generated=true

Pair the frozen install with an explicit integrity report in CI so a tampered or partial lockfile is caught before any code runs:

pnpm install --frozen-lockfile
pnpm list --recursive --depth=0   # fails if the resolved tree is incomplete

4. ESM/CJS boundaries and dual packaging

Every published package in the monorepo must declare an explicit exports map. Relying on the legacy main/module fields produces ambiguous resolution in Node.js and bundlers, and lets consumers reach into internal paths you never meant to expose. The interoperability rules — and the failure modes when you get them wrong — are the subject of ESM and CJS Interoperability, and the dual-output recipe is laid out in How to Configure package.json for Dual Modules.

{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    },
    "./package.json": "./package.json"
  }
}

Order matters inside each condition: types first so TypeScript resolves declarations, then import/require. A mismatched or missing require condition is the direct cause of the runtime ERR_REQUIRE_ESM failures documented in Fixing ERR_REQUIRE_ESM in Node.js. A different but related failure — an ESM consumer importing a named export that the package only exposes through a default CommonJS object — is covered in Resolving 'Named Export Not Found' in ESM. Validate every package's map with publint and attw (Are The Types Wrong?) in CI before it can be published.

Type declarations need the same dual treatment as runtime code, because a single index.d.ts written for one module system will misreport signatures to consumers using the other. Emit separate declaration files alongside the ESM and CJS bundles, and point each types condition at the matching one. The mechanics of producing both — and the Cannot find module declaration errors that result from skipping it — are covered in TypeScript Declaration Publishing, with the dual-emit recipe in Generating Dual CJS/ESM Type Definitions and the most common consumer-side error in Fixing 'Cannot Find Module' Type Declaration Errors.

5. Build orchestration, scripts, and the task graph

Monorepo performance hinges on modeling builds as a Directed Acyclic Graph (DAG). Build, test, and lint tasks must execute in topological order so a consumer never builds before its provider, while independent branches of the graph run in parallel. Task runners compute a cache key for each task from its source-file content hashes, declared inputs, environment variables, and the cache keys of its upstream dependencies — so a task is only re-executed when something it actually depends on changed.

The first decision is which engine to adopt; Choosing a Monorepo Task Runner frames the tradeoff between Turborepo's lightweight, config-driven model and Nx's deeper project-graph inference. With Turborepo you declare the graph explicitly in turbo.json per Turborepo Pipeline Configuration:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "test": { "dependsOn": ["build"] },
    "lint": {}
  }
}

The ^build token means "build every upstream dependency first." With Nx Workspace Architecture the graph is inferred from your import statements rather than declared, which powers the affected-project targeting detailed in Configuring Nx Affected Commands in CI. For a quantified comparison of cold-build and warm-cache timings, see Nx vs Turborepo Performance Benchmarks.

Scoping a run to a subset of the graph is where filtering earns its keep. With pnpm Workspace Filtering the --filter flag selects packages by name, glob, path, or graph relationship:

pnpm --filter "@repo/ui..." build

The trailing ... includes @repo/ui and every package that depends on it; the hands-on patterns are in Using pnpm --filter for Targeted Builds. Combined with a Git ref — --filter '...[origin/main]' — the same flag selects exactly the packages changed since a base branch plus their dependents, which is the foundation of fast pull-request CI: a one-line change to a leaf utility rebuilds only the handful of packages that actually consume it, while everything else is skipped or restored from cache.

Whether a script lives at the root or inside each package — and how those layers compose — is covered by Root-Level vs Package-Level Scripts. The general rule is to keep cross-cutting orchestration (build, test, lint, release) at the root where the task runner can see the whole graph, and keep package-specific lifecycle hooks inside each package. Fanning a single command out across the workspace — pnpm -r --parallel test, or a filtered subset — is the subject of Running Scripts Across Workspaces with pnpm.

The final multiplier is shared computation. Remote Caching Setup stores task outputs in an authenticated backend keyed by the same content hashes, so a CI runner — or a teammate — can download an artifact that someone else already built instead of recomputing it. Always pair remote caching with --frozen-lockfile: the lockfile hash is part of the cache key, and a drifting lockfile silently invalidates every entry. The CI-tuning, miss-debugging, and self-hosting paths are covered respectively by Optimizing Turborepo Remote Cache for CI, Fixing Turborepo Remote Cache Misses, and Self-Hosting a Turborepo Remote Cache.

When the graph is green and cached, release engineering takes over. Coordinated versioning, changelog generation, and provenance-signed publishing for interdependent packages are the domain of Package Publishing & Release Engineering, which automates the version bumps that internal workspace:* references would otherwise force you to hand-edit.

Common Mistakes

Mistake Impact Resolution
Omitting the packageManager field Different managers/versions produce divergent lockfiles across dev and CI Pin packageManager and add an only-allow preinstall guard
Allowing implicit cross-package imports that bypass exports Runtime resolution failures and leaked internals in production Declare explicit exports maps; validate with publint/attw
Running mutating install in CI Non-deterministic trees; phantom dependency bugs Use pnpm install --frozen-lockfile / npm ci / yarn --immutable
Over-hoisting internal packages to root node_modules Broken isolation; phantom dependencies pass tests then fail in prod Use node-linker=isolated (pnpm) or PnP (Yarn)
Skipping remote-cache auth tokens Every CI run rebuilds from scratch Configure TURBO_TOKEN / NX_CLOUD_ACCESS_TOKEN and --frozen-lockfile
Bare semver for internal deps Registry fetches stale published copies instead of local source Use the workspace:* protocol

Frequently Asked Questions

How do I enforce ESM/CJS boundaries in a monorepo? Define explicit exports maps in every package's package.json, set "type": "module" for ESM-first packages, and use dual-build tooling such as tsup or rollup to emit separate dist/esm and dist/cjs outputs with a types condition first. Validate every map with publint and attw in CI before publishing.

Why does my CI pipeline fail with lockfile mismatch errors? A mutating install ran somewhere, or the lockfile was committed out of sync with a package.json change. Always run pnpm install --frozen-lockfile (or npm ci / yarn install --immutable) in CI and pin the packageManager field so every environment resolves to an identical tree.

Should I use symlinks or hard links for workspace packages? Internal workspace references are symlinked by every modern manager so a single canonical copy reflects source edits immediately. Hard links are how pnpm deduplicates the content-addressable store into each package's node_modules; the two serve different layers, as explained in Workspace Symlinks vs Hard Links.

Turborepo or Nx — which task runner should I pick? Turborepo favors a lightweight, explicit turbo.json and minimal lock-in; Nx infers the project graph from imports and ships richer generators and affected-command tooling. Read Choosing a Monorepo Task Runner for the decision criteria and Nx vs Turborepo Performance Benchmarks for measured timings.

How do I safely manage cross-package dependencies without creating circular references? Enforce a strict DAG: keep shared types and stateless utilities in a leaf package that depends on nothing, and let feature packages depend only downward. Block regressions in CI with eslint-plugin-import/no-cycle or the @nx/enforce-module-boundaries rule. See Cross-Package Dependency Management for the full pattern.

Related

Home