Monorepos & Workspaces: pnpm, Turborepo & Nx
You split your product into three repos: the web app, a shared UI library, and a utilities package. It felt clean for about a month. Then you needed to add one prop to a Button, and shipping it turned into a four-step relay — publish @acme/ui, wait for the registry, bump the version in the app, install, test. A bug that would have been a two-line diff became an afternoon of version juggling. And because the three repos each had their own ESLint config, Vitest setup, and TypeScript version, “fix it in one place” was never actually one place.
A monorepo is the other answer: one repository, many packages, one install, one toolchain. It does not mean “one giant tangled codebase” — the packages stay separate and independently buildable. What changes is that they live and version together, so a cross-cutting change is a single commit and a single pull request. This article is about the machinery that makes that pleasant: workspaces to link the packages, a task runner with a cache so you never rebuild what did not change, and the tools you reach for when the repo gets big.
This builds on Package Managers in Depth and Modules, npm & Semantic Versioning — you’ll want the mental model of a lockfile and node_modules from those before going further.
One repo, many packages
The shape almost every JavaScript monorepo settles on is two top-level folders: apps/ for things you deploy, and packages/ for the shared code they import.
Teams reach for this arrangement for three concrete reasons:
- Shared code without a release cycle.
apps/webimports@acme/uidirectly. There is no publish step between changing the Button and seeing it in the app — the packages are wired together on disk. - Atomic cross-package changes. Renaming a function in
@acme/utilsand updating all six call sites across two apps and the UI library is one commit. Reviewers see the whole change; CI tests it as a unit; it either all lands or none of it does. No window where the repos are out of sync. - One toolchain. A single ESLint config, one TypeScript version, one test runner, one CI setup — defined once and inherited everywhere. The tenth package costs almost nothing to set up.
The cost is that you need tooling to keep a big repo fast, because a naive “test everything on every commit” gets slow the moment the repo grows. That tooling is the rest of this article.
Workspaces: one install, local links
A workspace is the package manager feature that makes a monorepo work. You tell your package manager “these folders are all part of one project,” and it does two things: installs every dependency of every package in a single pass, and links the internal packages to each other so an import resolves to the sibling folder instead of the registry.
With pnpm — the common choice in 2026 — you declare the members in pnpm-workspace.yaml at the repo root:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Each package keeps its own package.json. The interesting part is how one package depends on another. Instead of a version range that points at the registry, you use the workspace: protocol:
// apps/web/package.json
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*"
}
}
workspace:* means “always use the copy in this repo, whatever version it happens to be.” pnpm refuses to resolve it to anything on the registry — if the local package is missing, the install fails loudly instead of silently pulling a stranger’s package with the same name. One pnpm install at the root wires the entire graph together with symlinks. Change a line in @acme/ui, and @acme/web sees it immediately.
Catalogs: one version, declared once
There’s a related pnpm feature worth knowing early. If eight packages all depend on react, keeping those eight version ranges in sync by hand is a chore, and a merge conflict magnet. A catalog lets you name the version once:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
catalog:
react: ^19.1.0
zod: ^4.0.0
// any package.json in the repo
{
"dependencies": {
"react": "catalog:",
"zod": "catalog:"
}
}
Now the React version lives in exactly one place. Upgrading is a one-line edit, every package moves together, and you can never accidentally ship two versions of React into the same app. Like workspace:, the catalog: reference is replaced with the concrete range when you publish.
Task orchestration and caching: Turborepo
Workspaces solve linking. They do nothing about running tasks. You still need to build, lint, and test — across every package, in the right order (@acme/utils before @acme/ui before @acme/web), and ideally without redoing work that hasn’t changed.
That is what a task runner is for. In 2026 the default pairing for a JavaScript monorepo is pnpm workspaces plus Turborepo (currently 2.10.x, written in Rust by the Vercel team). Turborepo does two things that a plain pnpm -r run build cannot: it runs tasks in the correct dependency order automatically, and it caches the result of every task so an unchanged package is never rebuilt.
You describe your tasks in turbo.json at the root:
// turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
The dependsOn field is the whole game. "^build" (with the caret) means “before building this package, build all the packages it depends on.” So turbo run build figures out that @acme/utils must finish before @acme/ui, which must finish before @acme/web, and runs them in that order — parallelising whatever it can. outputs tells Turborepo which files a task produces, so it knows what to save and restore from the cache.
Content-aware hashing: the part that makes it fast
Before Turborepo runs a task, it computes a hash. Into that hash go the contents of the package’s source files, its package.json, the resolved versions of its dependencies, the task’s environment variables, and the hashes of any tasks it depends on. If that exact hash has been seen before, the task does not run at all — Turborepo replays the saved output (files and terminal logs) in a fraction of a second.
Run turbo run build twice in a row without editing anything and the second run finishes in well under a second — every task is a cache hit. This is why the day-to-day feel of a well-configured monorepo is fast despite its size: you only ever pay for what you actually changed.
Remote cache: your teammates skip the work too
The local cache lives in a folder on your machine, so it only helps you. The bigger win is the remote cache: a shared server that stores those same task outputs for the whole team and, crucially, for CI. When a teammate pulls your branch, or CI runs on a commit whose packages you already built, the output is downloaded instead of recomputed.
This is where the headline numbers come from — teams routinely report CI time dropping by 70–90% once the remote cache is warm, because most pull requests only touch a couple of packages and everything else is a download. Turborepo ships with Vercel’s remote cache built in (zero config if you’re on Vercel), and the cache protocol is open, so self-hosted and third-party implementations exist if you’d rather run your own.
See caching decide, step by step
Toggle which source files you “edited” and watch the pipeline decide, per task, whether to rebuild or restore from cache. The dependency graph is the one from the diagrams: web depends on ui and utils; ui depends on utils. A changed hash always flows downstream.
Notice the asymmetry: editing utils re-runs all three tasks, because everything depends on it. Editing web re-runs only web, because nothing depends on the app. That is exactly the shape you want — leaf packages are cheap to change, foundational packages are the expensive ones.
When you outgrow it: Nx
Turborepo is deliberately small: task graph, cache, done. For most teams that is the right amount of tool. But there is a point — usually a large repo with many teams, or one that mixes JavaScript with other stacks — where you want more structure, and that’s where Nx comes in. Nx is a full monorepo platform rather than just a task runner, and it brings three things Turborepo leaves to you.
Affected detection. Nx builds a detailed project graph and cross-references it with git history to answer a sharp question: given this diff, which projects can possibly be broken? Then nx affected -t test runs tests for only those projects and their dependents, skipping the rest entirely. On a big repo this is the difference between a two-minute CI run and a twenty-minute one.
Generators. nx generate scaffolds a new package or app from a template, wired into the workspace correctly — the right tsconfig, the right lint setup, the right dependencies registered — so the tenth package is as consistent as the first. When Nx or a plugin ships a breaking change, nx migrate can rewrite your config to match, which is a real relief at scale.
Distributed task execution. Nx Cloud can fan a single command out across many CI agents automatically. You declare “give me six agents,” and Nx distributes the tasks across them, respecting the dependency graph, then stitches the results back together. It is the cache idea taken one step further: not just skip redundant work, but parallelise the work that remains across machines.
TypeScript project references in a monorepo
There’s a subtlety TypeScript users hit early. If @acme/web imports from @acme/ui, does the compiler understand that they’re separate build units with their own outputs — or does it try to typecheck the whole repo as one blob every time? The mechanism that makes it the former is project references.
Each package gets a tsconfig.json marked as composite, which turns it into a citable build unit that emits declaration files:
// packages/ui/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../utils" }
]
}
A consumer lists the packages it depends on in its own references array. Then, instead of tsc, you build with tsc -b (build mode), which walks the reference graph, builds each project in dependency order, and — thanks to the .tsbuildinfo files it writes — skips any project whose inputs haven’t changed. It’s the same incremental idea as Turborepo’s cache, one layer down, inside the type checker itself.
A useful side effect: references enforce boundaries. TypeScript won’t let @acme/web import from @acme/emails unless that reference is declared. Your dependency graph stops being an accident of whatever someone happened to import and becomes something you actually decided.
Pitfalls worth knowing before you commit
Monorepos trade one class of problem (coordinating repos) for another (keeping one big repo healthy). The common traps:
- Circular dependencies.
@acme/uiimports from@acme/utils, and one day someone makes@acme/utilsimport from@acme/ui. Now neither can build first. Task runners will error, but the fix is architectural: extract the shared piece into a third package both can depend on. Catch these early — a lint rule ornx graphmakes cycles visible. - Over-hoisting and phantom dependencies. When a package uses something it never declared in its own
package.jsonbut that happens to be installed at the root, it works locally and breaks the moment it’s published or moved. pnpm’s strict, non-flatnode_moduleslayout makes this hard to do by accident — one of the strongest reasons it’s the monorepo default. See Package Managers in Depth for why the on-disk layout matters. - Versioning drift. Without discipline, different packages drift onto different versions of the same dependency and you ship three copies of
date-fns. Catalogs (above) and periodic dedupe checks keep this in line. - The “everything is affected” trap. If every package depends on one giant shared
@acme/core, then every change touchescoreand nothing is ever skippable. Keep shared packages small and focused so the affected graph stays narrow. A monorepo’s speed is only as good as its dependency graph is clean.
Publishing from a monorepo with Changesets
If some of your packages are meant for the outside world — an open-source library, an internal design system consumed by other repos — you need a way to version and publish them independently. Bumping every package to the same number on every release is crude; you want “@acme/ui had a feature, @acme/utils had a patch, @acme/web isn’t published at all.”
Changesets is the standard tool for this. The workflow is built around small intent files:
# after making a change, describe its release intent
pnpm changeset
# → pick which packages changed, pick patch/minor/major,
# write a one-line summary. Commit the generated file.
Each changeset is a tiny markdown file that says which packages should bump and by how much. They accumulate on main as pull requests land. When you’re ready to release, changeset version consumes all the pending files, bumps each package’s version (and bumps any package that depends on it), and updates every CHANGELOG.md. Then changeset publish pushes the changed packages to npm.
In practice you automate the middle: the official GitHub Action watches main, and whenever changesets are pending it opens (and keeps updating) a “Version Packages” pull request. Merging that one PR is your release. As of 2026 this pairs with npm’s OIDC trusted publishing (npm CLI 11.5.1+ on Node 22.14+), so CI authenticates to npm without a long-lived NPM_TOKEN secret sitting in your repo. The mechanics of building the release pipeline itself are covered in Publishing Packages to npm.
Summary
- A monorepo is one repository holding many separate, independently buildable packages — you get shared code with no release cycle, atomic cross-package changes, and one shared toolchain.
- Workspaces link the packages: declare members in
pnpm-workspace.yaml, depend on siblings withworkspace:*, and install the whole graph in one pass. Catalogs let you pin a shared dependency’s version in exactly one place. - Turborepo runs tasks in dependency order and caches each task by a content hash of its inputs, so unchanged packages are never rebuilt. Note it’s
tasksinturbo.json, not the oldpipeline. - The remote cache shares those task outputs across the team and CI, which is where the 70–90% CI reductions come from — most PRs only touch a couple of packages.
- Nx is the heavier platform for large or multi-team repos: affected detection, generators and migrations, and distributed task execution across CI agents.
- TypeScript project references (
composite+tsc -b) give you incremental, boundary-enforcing builds one layer below the task runner. - Watch for circular dependencies, phantom dependencies, version drift, and over-central shared packages — a monorepo is only as fast as its dependency graph is clean.
- Publish independently versioned packages with Changesets: intent files accumulate, a bot opens a Version Packages PR, merging it releases to npm.