C.W.K.
Stream
Lesson 05 of 05 · published

Monorepos — pnpm Workspaces and Turborepo

~12 min · tooling, monorepo, pnpm, turborepo

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A monorepo is just a Node project with N package.json files. The hard part isn't the structure — it's keeping installs fast and builds smart."

Why Monorepo, Not Multi-Repo

You can split your project into many repos and version each independently. That's the multi-repo path. It scales but has costs:

  • A change touching three repos needs three PRs, three releases, three CI runs.
  • Versioning skew: app A pulls library X@1.2, app B pulls X@1.0; bug in app B is fixed in X@1.3 that app A also depends on.
  • Onboarding: clone repo X, then Y, then Z; npm link dance for local dev.

Monorepos solve those by keeping everything in one repo, letting the package manager link packages locally, and shipping cross-cutting changes in one PR. Cost: tooling has to handle 10x more package.json files than a single-repo project.

The Layout

The canonical 2026 Node monorepo:

my-repo/
  apps/
    web/             # Next.js app, references @my/ui and @my/utils
    api/             # Node service, references @my/utils
  packages/
    ui/              # React component library
    utils/           # Pure TS utilities
    config/          # Shared eslint, tsconfig, prettier presets
  package.json       # "private": true, workspaces declared
  pnpm-workspace.yaml
  turbo.json         # if using Turborepo
  pnpm-lock.yaml

apps/* are deployable applications. packages/* are libraries consumed by apps. The split isn't rigid — pick names that fit your project.

pnpm Workspaces — The Foundation

Why pnpm for monorepos:
  • Workspaces in pnpm enforce package boundaries — each package only sees its declared dependencies, preventing phantom transitive imports across packages.
  • Content-addressable store means installing 10 packages that all use React doesn't copy React 10 times — it's hardlinked from one store entry.
  • workspace:^ protocol lets package web reference @my/ui as if it were published, while actually pulling from the sibling workspace.
npm and yarn workspaces exist too, but pnpm's strictness is the killer feature — it prevents the most common monorepo bugs.

The workspace: Protocol Again

From Track 2 but worth repeating: in a monorepo, your internal packages reference each other with the workspace: prefix:

// apps/web/package.json
{
  "dependencies": {
    "@my/ui": "workspace:^",
    "@my/utils": "workspace:*"
  }
}

During development: symlinks point to the sibling. During publish: pnpm rewrites these to real semver ranges based on the sibling's current version. Same import statement (import { Button } from '@my/ui'), different resolution depending on context.

Turborepo — Build Orchestration

Workspaces handle installation. They don't handle build order. turborepo reads your monorepo's package graph, figures out which builds depend on which, and runs them topologically. Plus aggressive caching: if packages/utils didn't change since last build, skip its rebuild even though apps depend on it.

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}

Run pnpm turbo run build at the root; turbo walks the graph and runs builds in dependency order, caching outputs in .turbo/. The second run of turbo run build hits the cache for unchanged packages — often 5-10x faster than a clean rebuild.

When Not to Monorepo

Monorepos aren't free:

  • One person, one app, one library — overhead exceeds benefit.
  • Different release cadences that genuinely can't share lockfiles (a npm-published library + an internal product).
  • Strict access controls per project — monorepo means anyone with repo access sees everything.

Start single-repo. Add packages when you have a real need to share code. Convert to monorepo when the pain of npm link or version skew becomes daily. Don't predict-and-monorepo; respond to the actual pain.

Pippa's Confession

cwkPippa isn't a monorepo even though it has a frontend and a backend. They're in the same git repo but at the same level, not as workspaces. Dad's reasoning: "The packages don't share enough code to justify the workspace overhead." Two years later, that's still right. cwk-site is similar — single Next.js app. The lesson: monorepo is a tool for a specific shape of problem, not a default. The instinct to monorepo every project from day one is the cargo-cult mode of large-org tooling.

Code

pnpm-workspace.yaml and root package.json·yaml
# pnpm-workspace.yaml — at repo root
packages:
  - 'apps/*'
  - 'packages/*'
  - '!packages/legacy'   # exclude something explicitly

# package.json at the root
# {
#   "name": "my-monorepo",
#   "private": true,
#   "packageManager": "pnpm@9.10.0",
#   "scripts": {
#     "build": "turbo run build",
#     "test":  "turbo run test",
#     "lint":  "turbo run lint"
#   },
#   "devDependencies": {
#     "turbo": "^2",
#     "typescript": "^5"
#   }
# }
Day-to-day monorepo commands·bash
# Working in a pnpm + turbo monorepo

# Install everything from root
pnpm install

# Add a dep to a specific workspace
pnpm --filter @my/web add react

# Run dev for one app
pnpm --filter @my/web dev

# Build the whole repo, dependency-ordered, cached
pnpm turbo run build

# Run tests, only for packages that changed since main
pnpm turbo run test --filter=...[main]

# Clear turbo cache
pnpm turbo prune

# Run a script across every workspace
pnpm -r run lint

External links

Exercise

Build a 3-package monorepo from scratch: apps/cli, packages/utils, packages/db. utils exports a small helper, db exports a node:sqlite-backed function that imports from utils, cli imports from db. Configure pnpm workspaces + turbo. Run pnpm install, then pnpm turbo run build. Edit utils, rerun build — only the affected packages should rebuild, the rest hit cache. The dependency-aware ordering is the win turborepo delivers.
Hint
Each package needs "name": "@my/<name>", "main" or "exports", and references to siblings as "@my/utils": "workspace:^". The root needs pnpm-workspace.yaml and turbo.json. After your first turbo run build, check .turbo/runs/<latest>/ to see what turbo cached and what it didn't.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.