Monorepo Tools — Turborepo, Nx, pnpm Workspaces
TL;DR
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.
Interview Q&A
Q: What problem does a monorepo solve?
A: 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.
Q: Workspaces — what do they actually do?
A: 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.
Q: What does Turborepo add?
A: 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.
Q: What does Nx add over Turborepo?
A: 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.
Q: Where does Lerna fit?
A: 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.
Q: How do you publish multiple packages from a monorepo?
A: 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.
Q: TypeScript in a monorepo — what’s the setup?
A: 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.
Q: Common monorepo layout.
A:
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.
Q: Versioning strategy — fixed vs independent?
A:
- 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.
Q: Affected-only builds in CI.
A: Only rebuild packages touched by the current PR. Massive CI time saver on big monorepos.
turbo run build --filter=...[origin/main] # build packages changed since main
turbo run test --filter=...[origin/main]
Or Nx:
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 / edge cases
- 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.
What a senior is expected to say
- “pnpm workspaces + Turborepo + Changesets is the modern default for new monorepos. Adds caching, parallelism, and clean release workflow on top of standard package-manager workspaces.”
- “Nx is heavier and more opinionated — first-class plugins for React/Next/Vue/Angular, generators, dep graph. Choose when you want batteries-included.”
- “Remote caching shares build artifacts across teammates + CI — first build wins, everyone else downloads. Major CI time saver.”
- “TS project references give incremental builds;
pathsmapping is the fallback. Worth setting up on any monorepo with shared internal packages.” - “Affected-only builds (
turbo --filter,nx affected) keep CI fast as the repo grows — never rebuild unchanged packages.” - “Changesets > Lerna for publishing; the intent (what changed + bump type) lives next to the code change.”
Cross-references
- Package managers (workspaces foundation): 07_package_managers.md
- Project structure / monorepo layout: ../12_project_structure/
- TypeScript strictness: ../04_typescript/
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/