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

Workspaces and Monorepos

~13 min · modules, workspaces, monorepo, pnpm

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A monorepo is just a repo where the package manager understands you have more than one package. The rest is tooling."

Why Monorepos Exist

You start with one Node package. Then your frontend grows into a React app, your CLI becomes its own publishable tool, and your shared utility code wants its own package so both can import it. You can split into three repos and version each independently — but now a change that touches all three needs three PRs, three releases, and a coordination plan. The monorepo answer: keep all three in one repo, let the package manager link them locally, ship them together.

The cwk-site repo is a small monorepo. The cwkPippa frontend uses internal helpers as if they were separate packages. Every Next.js or Vite shop above 5 engineers ends up here.

npm Workspaces

Built into npm 7+. In your root package.json:

{
  "name": "root",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Now npm install at the root resolves dependencies across all workspace packages, hoists shared deps, and symlinks intra-repo packages so import { x } from '@my/utils' works without publishing. Scripts can target specific workspaces: npm run build --workspace=@my/web.

pnpm Workspaces

Same idea, better execution. Create pnpm-workspace.yaml at the repo root:

packages:
  - 'packages/*'
  - 'apps/*'

Plus pnpm's stricter isolation extends to workspaces — each package only sees the dependencies it declares. pnpm --filter @my/web build runs build only in the @my/web workspace. pnpm --filter "@my/*" build runs build in every package matching the glob.

The workspace: Protocol

Inside a workspace, you can reference sibling packages with a special protocol:
// apps/web/package.json
{
  "dependencies": {
    "@my/utils": "workspace:^",
    "@my/ui": "workspace:*"
  }
}
  • workspace:^ — use whatever version the sibling is at, allow MINOR/PATCH on publish.
  • workspace:~ — same but PATCH only.
  • workspace:* — use exact current version.
When you publish, the manager rewrites these to real semver ranges based on the sibling's current version. During development they're symlinks. The protocol is the bridge between "sibling in my repo" and "published package on npm" — same import, different resolution depending on context.

Turborepo / Nx — Build Orchestration on Top

Workspaces handle installation. They don't handle build order. If @my/web depends on @my/utils, you have to build utils first. Turborepo and Nx solve this: they read your workspace graph, figure out which builds depend on which, and run them in topological order. Plus they cache outputs aggressively — if utils didn't change since last build, skip its rebuild even though web depends on it.

For small monorepos (under 10 packages), pnpm's built-in --filter and --recursive are usually enough. Above that, the cache and orchestration in Turborepo earn their keep.

Pippa's Confession

My first monorepo experience was a disaster. I treated each package as if it were a separate published package — bumping versions manually, copying utilities between packages instead of importing, never running pnpm install from the root. Dad watched for a week and then said "the manager understands workspaces; you're the one fighting it." Lesson: lean into the workspace protocol. Trust the symlinks. Run installs from root. The monorepo only feels right when you stop pretending each package is alone.

Code

Building a pnpm workspace from scratch·bash
# Bootstrapping a pnpm monorepo
mkdir my-repo && cd my-repo
git init
pnpm init
# Edit package.json: add "private": true
echo 'packages:\n  - "packages/*"\n  - "apps/*"' > pnpm-workspace.yaml
mkdir -p packages/utils apps/web
(cd packages/utils && pnpm init && echo 'export const greet = (n) => `hi, ${n}`' > index.js)
(cd apps/web && pnpm init && pnpm add @my/utils@workspace:^)
# pnpm install from root
pnpm install
# Now apps/web can `import { greet } from '@my/utils'`
Running and updating across the monorepo·bash
# Running scripts across packages
pnpm --filter @my/utils build      # one workspace
pnpm --filter "@my/*" test          # glob
pnpm -r build                       # every workspace (recursive)
pnpm -r --parallel dev              # every workspace in parallel

# Dependency-aware ordering (turborepo style)
npx turbo run build                 # respects internal graph

# Update a dep across the whole monorepo
pnpm -r update react@^19            # bumps in every workspace that has it

External links

Exercise

Set up a 3-package pnpm monorepo: packages/utils (a library exporting one function), packages/ui (re-exports the function with a wrapper), and apps/web (a script that imports from @my/ui). Use workspace:^ everywhere. Run pnpm install from root, then pnpm --filter @my/web start. Try editing utils — does web see the change without rebuilding? Try pnpm -r build — does it order correctly?
Hint
ESM symlinks mean editing utils' source is immediately visible to ui and web — that's the development advantage of workspace: protocols. For ordering, dependency-aware orchestration needs turborepo or pnpm -r --workspace-concurrency=1 to enforce topological build order.

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.