-
Pick the orchestrator by scale — default to pnpm workspaces + Turborepo. Don't reach for Nx or Bazel unless a row below forces it.
| Tool | Use when | Cost / friction |
|---|
| pnpm workspaces + Turborepo | JS/TS monorepo, you want caching + affected runs with near-zero config — the default | Tiny turbo.json; no codegen, no plugins |
| Nx | You need code generators/scaffolding, an enforced module-boundary lint rule, or rich project-graph tooling | Heavier config, nx.json + project.json or inferred targets, more to learn |
| Bazel / Buck2 | Polyglot at scale (JS + Go + Java + protos), hermetic builds, thousands of targets | High: BUILD files everywhere, steep ramp — only worth it at large org scale |
| Lerna (alone) | — | Legacy; for new repos use pnpm + Turbo/Changesets instead |
Use pnpm as the package manager regardless (strict, fast, disk-efficient, first-class workspace: protocol). Commit pnpm-lock.yaml.
-
Lay out the workspace and declare it. apps/* = deployables (not published), packages/* = shared libs (publishable or internal). Root is private.
packages:
- "apps/*"
- "packages/*"
{ "name": "@acme/root", "private": true, "packageManager": "pnpm@9.12.0" }
Name every internal package under one scope: @acme/ui, @acme/config, @acme/api-client. The scope makes ownership and the dependency graph legible at a glance.
-
Wire internal deps with the workspace: protocol — never a version range. In a consumer's package.json:
"dependencies": { "@acme/ui": "workspace:*" }
workspace:* symlinks the local source so changes are picked up instantly; at publish time Changesets/pnpm rewrites it to the real version. Run pnpm install from the root once — it links everything. A range like "^1.0.0" instead would silently pull the registry copy, defeating the monorepo.
-
Keep the package graph acyclic and explicit. Cycles break topological build order and caching. Enforce it, don't hope:
- Direction:
apps → packages → packages. Apps depend on packages; packages never depend on apps. Leaf utils depend on nothing internal.
- Every cross-package import must correspond to a declared
dependency in that package's package.json — no reaching into a sibling's ../other-pkg/src. Set eslint-plugin-import/no-relative-packages (or Nx's enforce-module-boundaries) to ban it.
- Detect cycles in CI:
pnpm dlx madge --circular --extensions ts,tsx packages apps must exit 0. Turbo also errors on a cyclic task graph.
-
Define the task pipeline with dependsOn + inputs/outputs — this is what makes caching work. A task's cache key = its declared inputs + its dependencies' outputs; get these wrong and you get false hits (stale) or zero hits (no speedup).
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"], "outputs": ["coverage/**"
-
Turn on remote cache so CI and teammates share results. Local cache only helps the same machine. npx turbo login && npx turbo link (Vercel Remote Cache) or self-host with a TURBO_TOKEN + TURBO_API env in CI. Now a build artifact produced on one PR/runner is reused everywhere — the single biggest CI-time win.
-
Run affected/changed-only in CI. Replace "build everything" with a graph-filtered command so untouched packages are skipped (cache hit) or never scheduled:
- Turbo:
turbo run build test lint --filter='...[origin/main]' — runs only packages changed since main plus everything that depends on them.
- Nx:
nx affected -t build test lint --base=origin/main.
- Require
fetch-depth: 0 (full history) in CI checkout or the merge-base is wrong and the filter degrades to "run everything." This is the most common reason affected runs silently rebuild all.
-
Share one base config; extend per package — don't copy. A single source of truth in a @acme/config package, extended by every other package:
{ "compilerOptions": { "strict": true, "composite": true, "declaration": true } }
{ "extends": "../../tsconfig.base.json", "include": ["src"], "compilerOptions": { "outDir": "dist" } }
Same pattern for ESLint flat config and Prettier: define once in @acme/config, import/extends it everywhere. Per-package files hold only the genuine deltas (paths, env). (The lint/format/hook content itself → setup-lint-format-precommit.)
-
Add Changesets for versioning/release intent. pnpm add -Dw @changesets/cli && pnpm changeset init. Workflow: contributor runs pnpm changeset (records which packages changed + semver bump + a user-facing note); CI runs changeset version to bump versions, rewrite workspace:* → real versions, and update CHANGELOGs, opening a "Version Packages" PR. Set "linked"/"fixed" groups in .changeset/config.json only if packages must move in lockstep. The actual npm publish after merge is publish-package-registry's job.