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:
- Shared code without npm-publish-per-change. A
packages/uilibrary used byapps/webandapps/admin— edit the lib, both apps update immediately. - Atomic cross-package changes. Update an API client + every consumer + tests in one PR.
- 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.
// root package.json
{
"name": "monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}pnpm uses pnpm-workspace.yaml:
packages:
- "packages/*"
- "apps/*"Effects:
- One
node_modulesinstall for the whole repo (or per-package with pnpm’s isolated mode). - Internal packages are symlinked —
apps/webdepends on@my/uifrompackages/uidirectly, not from npm. npm install lodashfrom 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:
- Builds a task dependency graph from your
turbo.jsonconfig. - Runs tasks in parallel where dependencies allow.
- Caches outputs (locally and optionally remote) keyed by input hashes.
- Skips tasks whose inputs haven’t changed — a re-run of
turbo buildon unchanged code takes seconds.
// 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 graphopens an interactive graph of your packages. - Affected commands —
nx affected --target=testruns 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):
- Developer makes a change, runs
pnpm changeset— picks affected packages + bump type (patch/minor/major) + summary. - Commits the changeset markdown file.
- CI on
main: opens a “Version Packages” PR that aggregates pending changesets, bumps versions, generates changelogs. - 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:
// 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.
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.
# build packages changed since main
turbo run build --filter=...[origin/main]
turbo run test --filter=...[origin/main]Or Nx:
nx affected --target=build
nx affected --target=testBoth 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/uidepends on@my/utilsdepends 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 +
pathsmapping usually work.
Related
Further reading
- Turborepo docs: https://turbo.build/repo/docs
- Nx docs: https://nx.dev/
- pnpm workspaces: https://pnpm.io/workspaces
- Changesets: https://github.com/changesets/changesets
- “Monorepo Tools” comparison: https://monorepo.tools/
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 --filterornx 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.
pathsmapping 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.