Frontend / Build tools / 06_monorepo_tools.md

Monorepo Tools — Turborepo, Nx, pnpm Workspaces

Updated 5 interview angles 7 min read source
On this page14
  1. What problem does a monorepo solve?
  2. Workspaces — what do they actually do?
  3. What does Turborepo add?
  4. What does Nx add over Turborepo?
  5. Where does Lerna fit?
  6. How do you publish multiple packages from a monorepo?
  7. TypeScript in a monorepo — what’s the setup?
  8. Common monorepo layout.
  9. Versioning strategy — fixed vs independent?
  10. Affected-only builds in CI.
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Monorepo Tools — Turborepo, Nx, pnpm Workspaces

A monorepo holds multiple packages in one repository — typically for a frontend + backend + shared lib, or a design system + multiple consumers. The senior question is what tooling layer you need: package manager workspaces (npm/pnpm/yarn) handle install + linking; Turborepo adds caching + parallel task execution; Nx adds opinionated generators + dependency graph + plugins; Lerna is the legacy choice (still maintained but no longer best-in-class). For 90% of new monorepos: pnpm workspaces + Turborepo. Switch to Nx if you want batteries-included structure or plugin ecosystems.

What problem does a monorepo solve?

Three things:

  1. Shared code without npm-publish-per-change. A packages/ui library used by apps/web and apps/admin — edit the lib, both apps update immediately.
  2. Atomic cross-package changes. Update an API client + every consumer + tests in one PR.
  3. Unified tooling. One CI config, one TS setup, one lint config — applied everywhere.

The cost: bigger repo, more complex builds, more coordination.

Workspaces — what do they actually do?

Workspaces tell your package manager that multiple package.jsons in subdirectories form one install graph.

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

pnpm uses pnpm-workspace.yaml:

yaml
packages:
  - "packages/*"
  - "apps/*"

Effects:

  • One node_modules install for the whole repo (or per-package with pnpm’s isolated mode).
  • Internal packages are symlinked — apps/web depends on @my/ui from packages/ui directly, not from npm.
  • npm install lodash from a sub-package adds it there but shares hoisted copy.

This is enough for many monorepos. Tools like Turborepo build on top of workspaces; they don’t replace them.

What does Turborepo add?

Task orchestration + caching. Run turbo build and Turborepo:

  1. Builds a task dependency graph from your turbo.json config.
  2. Runs tasks in parallel where dependencies allow.
  3. Caches outputs (locally and optionally remote) keyed by input hashes.
  4. Skips tasks whose inputs haven’t changed — a re-run of turbo build on unchanged code takes seconds.
json
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],          // builds dep packages first
      "outputs": ["dist/**", ".next/**"],
      "inputs": ["src/**", "package.json", "tsconfig.json"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,                   // long-running, don't cache
      "persistent": true
    }
  }
}

^build means “build all dependencies of this package first.”

Remote caching (turbo run build --remote-cache) shares the cache across teammates + CI — first dev to build pushes to the cache, everyone else downloads instead of building. Major win.

What does Nx add over Turborepo?

Nx is more opinionated — task orchestration + caching like Turbo, plus:

  • Plugins per ecosystem — first-class React, Next.js, Vue, Angular, Node, Storybook setups.
  • Generators / scaffolding — nx generate @nx/react:lib my-lib.
  • Dependency graph viz — nx graph opens an interactive graph of your packages.
  • Affected commands — nx affected --target=test runs tests only for packages touched by the current branch.
  • Computation cache (similar to Turbo’s).

Nx is heavier — more config, more conventions. Worth it when:

  • You want strong scaffolding / generators.
  • You manage many packages across teams.
  • You want Angular/Nest support (Nx grew out of the Angular world).

Turborepo is lighter and more flexible. Choose based on team appetite for opinion.

Where does Lerna fit?

Lerna was the original JS monorepo tool (2015). Maintained but no longer best-in-class:

  • lerna publish — version + publish multiple packages. Changesets (below) is now preferred.
  • lerna run — run a script across packages. Turbo run does this faster with caching.
  • Hoisting / bootstrapping — package manager workspaces obsoleted these.

For new monorepos: skip Lerna. For existing Lerna repos: gradual migration to pnpm + Turbo + Changesets.

How do you publish multiple packages from a monorepo?

Changesets (https://github.com/changesets/changesets):

  1. Developer makes a change, runs pnpm changeset — picks affected packages + bump type (patch/minor/major) + summary.
  2. Commits the changeset markdown file.
  3. CI on main: opens a “Version Packages” PR that aggregates pending changesets, bumps versions, generates changelogs.
  4. Merge that PR → CI publishes new versions to npm.

Beats Lerna’s all-in-one publish because the intent (what changed and why) is committed alongside the code, not derived at release time.

TypeScript in a monorepo — what’s the setup?

Project references:

json
// packages/ui/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

// apps/web/tsconfig.json
{
  "compilerOptions": { /* ... */ },
  "references": [{ "path": "../../packages/ui" }]
}

composite: true enables incremental builds (TS caches per-project). references tells TS that apps/web depends on packages/ui — IDE jumps to source, builds in the right order.

Without project references, TS can still work via path mapping (paths in tsconfig), but you lose incremental builds and may hit “this is too slow on a big repo” issues.

Common monorepo layout.

text
my-monorepo/
├── package.json                  # workspaces, scripts, root devDeps
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json            # shared TS config
├── apps/
│   ├── web/                      # Next.js app
│   ├── admin/                    # Vite app
│   └── docs/                     # Astro/Docusaurus
├── packages/
│   ├── ui/                       # design system
│   ├── api-client/               # shared client
│   ├── utils/
│   └── tsconfig/                 # shared TS configs as a package
└── tooling/
    ├── eslint-config/
    └── prettier-config/

apps/ are deployable; packages/ are internal libs; tooling/ are shared dev configs. Some teams flatten this (packages/apps/web); the split is convention.

Versioning strategy — fixed vs independent?

  • Fixed (lockstep): all packages share one version (e.g., everything bumps from 1.4 to 1.5). Simpler but couples release cadences.
  • Independent: each package has its own version. More work, more flexibility — the right answer for most npm-publishing monorepos.

Lerna and Changesets both support both. Independent is the default for serious libraries.

Affected-only builds in CI.

Only rebuild packages touched by the current PR. Massive CI time saver on big monorepos.

bash
# build packages changed since main
turbo run build --filter=...[origin/main]
turbo run test --filter=...[origin/main]

Or Nx:

bash
nx affected --target=build
nx affected --target=test

Both walk the package graph and run tasks only for packages whose inputs (or whose dependencies’ inputs) changed.

Gotchas

  • pnpm strict mode prevents importing a package that’s not declared in your package.json. Catches accidental cross-package dependencies but breaks some legacy setups.
  • Hoisting (shamefully-hoist) in pnpm — flattens deps for tools that don’t understand pnpm’s structure. Use sparingly.
  • Circular package dependencies — @my/ui depends on @my/utils depends on @my/ui. Refactor; tools won’t fix it.
  • Build outputs in version control — should be .gitignored. CI rebuilds; devs rebuild locally.
  • Remote cache hits in CI but not locally (or vice versa) — usually a token/auth issue or a non-hermetic input the cache doesn’t see (env vars, OS-specific deps).
  • TypeScript project references slow on first build but fast on incremental — measure both.
  • Storybook in a monorepo — wants to find packages by source; project references + paths mapping usually work.

Further reading

Interview angle 5

  • “What would you reach for on a new monorepo?” - pnpm workspaces for linking, Turborepo for task orchestration and caching, Changesets for releases. It layers onto standard package-manager workspaces rather than replacing them, so you can remove any one piece later.
    • Follow-up: “When would Nx be the better call?” - when you want batteries included: generators, first-class framework plugins, an enforced dependency graph. It is heavier and more opinionated, which is a cost on a small repo and a benefit on a large one with many teams.
  • “What does remote caching actually buy?” - the first person or CI job to build an unchanged package uploads the artifact and everyone else downloads it. On a large repo it is usually the single biggest CI time saving available, because most packages are unchanged in most PRs.
  • “How do you keep CI fast as the repo grows?” - build only what changed, via turbo --filter or nx affected, computed from the dependency graph. Rebuilding everything on every PR is what makes people hate monorepos.
  • “How do you get incremental TypeScript builds?” - project references, so each package emits declarations that downstream packages consume without re-checking the source. paths mapping is the simpler fallback and gives up the incrementality.
  • “Changesets or Lerna?” - Changesets, because the intent is captured in the PR that made the change: a file saying what changed and what bump it deserves, reviewed alongside the code. Deriving it from commit messages afterwards is guessing.