open atlas
↑ Back to track
Next.js, zero to senior NEXT · 08 · 02

Monorepo mechanics: pnpm workspaces, the Turborepo task graph, and packages that earn their boundary

pnpm workspaces link internal packages; Turborepo hashes task inputs and replays cached outputs — 38-minute cold CI becomes single-digit warm builds. transpilePackages ships source-TSX ui packages; changesets only matter when publishing. A god utils package erases every win.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Three Next.js repos — web, admin, docs — and a copy-pasted design system between them. A security fix to the shared fetch wrapper (it was logging Authorization headers on failure) lands in two repos out of three; the third leaks tokens to a log aggregator for five weeks until a pentest finds it. The incident review mandates a monorepo: one fix, one commit, every app. Six months later the monorepo is the new incident: every PR — a typo in docs included — runs 38 minutes of CI. Turborepo is installed, the cache is configured, and the hit rate is 4 percent, because the migration created packages/shared, a utils dumping ground that all eight packages import and that changes in seven PRs out of ten. One line in shared re-hashes every task in the graph. The fix was not a tool; it was a boundary: splitting shared into focused domain packages took the cache hit rate to 85 percent and median PR CI from 38 minutes to 6. The monorepo was never the point. The graph was.

Before you add Turborepo, the question is what gives you actual boundary enforcement rather than just folder organization. The answer is the package manager substrate.

The substrate is pnpm-workspace.yaml declaring apps/* and packages/*. Each package keeps a real package.json, and internal dependencies use the workspace protocol — "@acme/ui": "workspace:*" — which pnpm resolves to a symlink into the local package, never the registry. Two properties make this the boundary substrate rather than a convenience. First, every package declares its dependencies explicitly: the package.json is the boundary contract, machine-checked on install. Second, pnpm’s strict, non-flat node_modules means a package can only resolve what it declared — the classic phantom dependency (importing a package that some hoisted neighbor happened to install) fails fast instead of working in dev and detonating in a different install order later. A monorepo on a hoisting package manager has folders; a monorepo on pnpm has enforceable edges.

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
// apps/web/package.json — the edge is declared, not implied
{
  "name": "web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/billing": "workspace:*",
    "next": "15.1.0"
  }
}

The task graph is the cache graph

Turborepo reads turbo.json and builds a directed graph of tasks: "build": { "dependsOn": ["^build"] } means “my build needs my dependencies’ builds first” — topological order falls out. The leverage is in what happens before a task runs: Turborepo computes a content hash over the task’s inputs — the package’s source files, the hashes of its dependencies’ outputs, the declared environment variables, the relevant lockfile slice, and the task configuration itself. Hash seen before: skip the work and replay the cached outputs and the captured logs, byte for byte. Hash new: run and cache. Because a dependency’s output hash is an input to every dependent’s hash, invalidation travels exactly along the dependency arrows — and nowhere else.

The honest numbers from the repo in the hook: a cold full build (nine packages, two Next.js apps) runs 20–35 minutes in CI. With a remote cache, the hash-keyed artifacts live in shared storage: CI builds once, every other runner — and every laptop — replays. Warm median PR: 4–7 minutes, because only the changed subgraph executes; a PR touching nothing buildable replays everything in under a minute. Add --filter=...[origin/main] and CI does not even consider tasks outside the affected subgraph. None of this requires the cache to be clever. It requires the graph to be cuttable — which is a property of your package boundaries, not of Turborepo.

Quiz

You change one line in packages/utils, which ui and both apps depend on (ui also depends on utils). turbo run build executes with a warm remote cache. What runs?

Extracting packages: ui, config, domain

What earns extraction out of an app: code consumed by two or more apps, testable in isolation, owning its own dependencies. Three archetypes cover most repos. packages/ui — the design system — has a real decision attached: ship it as source (the package exports raw .tsx, and each Next.js app lists it in transpilePackages so the app’s compiler processes it like its own code) or prebuilt (the package gets a tsup/rollup build task emitting dist/). Source-shipped wins on iteration speed: zero bundler config in the package, and editing a button hot-reloads in every running app instantly. Its cost is symmetrical: every consuming app recompiles the package on every build, and that compilation is cached as part of the app build, never as its own task. Prebuilt inverts the trade — the package build is cached and shared, app builds are faster, publishing externally becomes possible — at the price of bundler config, watch-mode orchestration, and the “works in app A, stale dist in app B” class of bug. Default to source plus transpilePackages; switch when app build time or external publishing forces you.

// apps/web/next.config.js
module.exports = {
  transpilePackages: ['@acme/ui'], // compile source-shipped internal packages
};

packages/config holds eslint-config and tsconfig bases that every package extends — one upgrade PR moves the whole repo, drift becomes structurally impossible. packages/billing-style domain packages are the highest-value extraction: pure TypeScript, no React, so typechecking and testing business rules no longer requires building an app at all.

Order the steps

A developer edits one line in packages/utils. Order the Turborepo cache invalidation chain that follows:

  1. 1 Turborepo recomputes the content hash for packages/utils — source files changed, so it is a cache miss
  2. 2 packages/ui and packages/billing recompute their hashes — their dependency output hash (utils) is an input, so they also miss
  3. 3 apps/web and apps/admin recompute their hashes — they depend on ui and billing, whose output hashes just changed
  4. 4 Turborepo runs build/typecheck for every node that missed; unaffected packages (e.g. packages/config) replay from remote cache unchanged

Versioning honestly

Internal packages need no versions. workspace:* means every commit is the version: consumers always compile against HEAD, there is nothing to publish, nothing to bump, no semver to argue about in review. Changesets earn their ceremony in exactly one case — when a package is published outside the repo (npm or a private registry) for consumers who are not in the workspace. Adopting changesets for internal-only packages produces version-bump PRs that no human and no machine consumes: pure process theater. The honest rule: workspace:* until the day an external consumer exists, changesets from that day for the published packages only.

Quiz

packages/ui exports raw .tsx with no build step. apps/web imports a Button from it and the build fails on unexpected syntax. What is the supported fix, and what does it cost?

The payoff, and the god package that erases it

The boundary payoff compounds in CI: turbo run typecheck test --filter=...[origin/main] means a PR touching packages/billing runs billing’s checks plus the two apps that import it — the docs app and five other packages replay from cache. Typecheck time stops scaling with repo size and starts scaling with blast radius.

The failure mode is the one from the hook, and it deserves its mechanism spelled out: packages/shared — utils, helpers, “common” — imported by every package. Its position in the graph means its hash feeds every other hash; its grab-bag nature means it changes constantly (70 percent of PRs in the incident repo). Multiply: near-zero cache hits, full-graph rebuilds on every change, and a team concluding “the monorepo made CI slower” when the graph topology did. Detection is mechanical — render the package graph (turbo can; dependency-cruiser for finer grain) and track cache hit rate per PR as a first-class CI metric. The fix is splitting by domain and change frequency: stable leaves (@acme/result, @acme/dates) separated from hot domain logic, so a billing change re-hashes billing’s dependents and nothing else. The cultural fix matters more: never let a package be named shared or utils again — names that cannot say no to a new export.

Recall before you leave
  1. 01
    How does Turborepo decide to skip a task, and what makes the remote cache the actual payoff?
  2. 02
    When do internal packages need changesets, and what is the god-utils failure mode with its fix?
Recap

The copy-paste incident — a token-logging fix that reached two repos out of three — buys the monorepo, and the 38-minute CI buys the real lesson: the monorepo pays off only as a graph. pnpm workspaces supply the substrate: workspace:* links internal packages by symlink, each package.json declares its edges explicitly, and strict node_modules turns phantom dependencies from latent production bugs into instant install-time failures. Turborepo turns the dependency graph into a cache: each task keyed by a content hash over sources, dependency output hashes, env vars, lockfile slice, and config — hit means replaying outputs and logs, miss means run and store, and the remote cache shares artifacts so CI builds once and everything else replays. Numbers worth keeping: 20–35-minute cold builds become 4–7-minute warm PR medians, with affected-only filtering excluding the rest of the graph entirely. Extraction has three archetypes — ui shipped as source with transpilePackages (instant cross-app HMR, but each app recompiles it) versus prebuilt (own cached build task, at the cost of bundler config and stale-dist bugs); config packages that make eslint/tsconfig drift impossible; and pure-TS domain packages, the highest-value cut because business logic stops needing an app build to typecheck. Version nothing internal — workspace:* means HEAD is the version, and changesets are ceremony until a package is actually published. And the failure mode that erases every win: the god utils package, positioned under the entire graph and changed in most PRs, driving the hit rate to 4 percent. Split by domain and change frequency, watch cache hit rate per PR as a CI metric, and never name a package shared. Now when you see CI times creeping up in a monorepo, render the dependency graph before reaching for a faster machine — odds are one package sits under everything and changes constantly.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.