Monorepos
One repository for many apps and shared packages, plus what makes it scale: workspaces that link packages, task runners that build only what changed and cache the rest, folder owners, and a rule for versioning internal code.
The problem: one button, five repositories
Picture a travel company with three web apps: the public booking site, the customer account area, and an internal admin tool. All three use the same Button, the same date picker, and the same API client. Each app lives in its own Git repository, and the shared code lives in two more: ui-kit and api-client. Five repositories in total.
A designer asks for a small change: the primary button needs a loading state. The engineer opens a pull request in ui-kit, gets it reviewed, merges it, and publishes version 4.3.0 to the private npm registry. That is step one of many.
Now each app has to take the new version. Three more pull requests, three more reviews, three more CI runs. The admin tool is still on ui-kit version 3 because nobody had time to handle a breaking change from last spring, so it cannot take 4.3.0 at all. Two weeks later the booking site has the loading button, the account area has it on a branch, and the admin tool never gets it.
What goes wrong with many repositories
Keeping each project in its own repository is called a polyrepo setup. It is a fine choice for many teams, but when several apps share a lot of code, it creates four kinds of friction.
- Slow cross-cutting changes. A change to shared code takes one pull request per repository, in order: publish first, then upgrade each user.
- Version drift. Each app pins its own version of the shared code. Over time the apps spread across
ui-kit3, 4, and 5, and bugs fixed in 5 stay in 3. - Hidden breakage. The author of a shared package cannot easily run the apps' tests before publishing, so a break shows up weeks later, when an app upgrades.
- Copied setup. Every repository has its own TypeScript config, ESLint rules, and CI pipeline. They start as copies and drift apart; a CI fix is made five times.
What a monorepo is (and is not)
A monorepo is one version-control repository that holds many projects: several apps, the shared packages they use, internal tools, and shared configuration. In the travel company's case, all five repositories become folders in one repository: apps/booking, apps/account, apps/admin, packages/ui, and packages/api-client.
Now the loading button is one pull request. It changes packages/ui and the apps that need the new prop, and CI runs the tests of every app that uses the button. If the admin tool breaks, the pull request is red before anything merges.
A monorepo is not a monolith, which is one app built and deployed as a single unit. The three apps still have separate builds and deploys. What they share is the repository, the tooling, and the ability to change many of them in one commit.
The new problems a monorepo brings
Without tools, a monorepo gets slow and messy as it grows. Most of this lesson is about those tools.
- Linking. How does
apps/bookingimportpackages/uiwithout publishing it? Answer: workspaces. - Speed. If every pull request builds and tests everything, CI time grows with the whole repository. Answer: a task runner that runs only affected work and caches results.
- Ownership. With 40 teams in one repository, who must review a change to
packages/ui? Answer: aCODEOWNERSfile and boundary rules. - Versions. Do internal packages have version numbers at all? Answer: a clear policy, often none for internal code and changesets for anything published.
Check your understanding · Exercise 1
A team moves its three apps and its component library into one repository. Each app keeps its own build and deploy pipeline. Which statements are true? Select all that apply.
The mental model: one repository, one graph
The one picture to keep in your head is a dependency graph. Every app and package is a node. An arrow from booking to ui means "booking depends on ui". Monorepo tools read this graph from the package.json files, and almost every feature they offer is a question asked of it.
Think of a city's water pipes. After repairing one main pipe, you check only the houses downstream of it. And a street whose pipes have not changed since last week keeps last week's inspection report.
Reading the graph in two directions
The graph is useful in both directions. Take the travel company's repository in the figure above.
- Dependencies (follow the arrows forward).
bookingdepends onuiandapi-client, and through them ontokensandutils. To buildbooking, those must be built first. This direction answers "what must run before me?" - Dependents (follow the arrows backward).
utilsis used byuiandapi-client, and through them by every app. This direction answers "what could my change break?" A package with many dependents is risky to change and expensive to test.
Every arrow should point from something more specific to something more general: apps depend on features, features depend on shared UI, shared UI depends on tokens and utilities. The graph must have no cycles. If ui imports from api-client and api-client imports from ui, neither can be built first, and tools will refuse to run or report an error.
From the package graph to the task graph
Packages do not run; tasks do. A task is one script in one package, such as ui#build or booking#test. The tool combines the package graph with rules you write, like "before building a package, build its dependencies", to produce a task graph: which tasks must finish before which.
The task graph gives you three features at once:
- Correct order.
tokens#buildruns beforeui#build, which runs beforebooking#build. Nobody has to write that order by hand in a CI script. - Parallelism. Tasks with no path between them run at the same time.
utils#buildandtokens#builddo not depend on each other, so they share the CPU cores. Lint tasks usually have no dependencies at all, so all of them can start at once. - Skipping. If a change touched only
api-client, thenuiandtokenscannot be affected by it. Their tasks can be skipped entirely (an affected run) or answered from a cache (a cache hit).
The shape of the graph decides your CI cost
A wide, shallow graph with small packages is cheap: most changes touch one leaf and affect one or two apps. A graph where one giant common package sits under everything is expensive: almost every pull request changes common, so almost every pull request affects everything. Tools cannot save you from that shape. Splitting the giant package is the fix.
Check your understanding · Exercise 2
In the graph above, a pull request changes only tokens. Which projects are affected and need their tests run? Select all that apply.
Workspaces: linking packages without publishing
The foundation of every JavaScript monorepo is the package manager's workspaces feature. npm (since version 7), Yarn, pnpm, and Bun all have one. You tell the package manager which folders hold packages, and a single install at the root sets up all of them together.
The key trick is linking. When apps/booking lists @travel/ui as a dependency, the package manager notices that a package with that name lives in packages/ui. Instead of downloading it from a registry, it creates a symbolic link (a pointer to another folder) in booking's node_modules. An edit to packages/ui/src/Button.tsx is visible to booking at once, with no publish and no reinstall.
# pnpm-workspace.yaml (at the repository root)
packages:
- "apps/*"
- "packages/*"
# apps/booking/package.json (the relevant part)
# "dependencies": {
# "@travel/ui": "workspace:*",
# "@travel/api-client": "workspace:*",
# "react": "^19.1.0"
# }
workspaces array in the root package.json.The workspace protocol
The version workspace:* is the workspace protocol, supported by pnpm, Yarn (version 2 and later), and Bun. It means "always use the local copy, never the registry". If the local package is missing, the install fails loudly instead of quietly downloading some old published version with the same name. npm does not support this protocol; with npm you write a normal range such as * and npm links the local package when its version matches.
When you publish a package that uses it, pnpm and Yarn replace workspace:* with the real version number, so it is safe for published packages too.
One install, one lockfile
A workspace has a single lockfile at the root (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock). It records the exact version of every third-party package for every project. A new engineer runs one install and every app works, and an upgrade such as React 18 to 19 is one diff that CI checks against every app.
How pnpm stores packages, and why it is strict
pnpm is a common choice for monorepos because of how it lays out files. Classic npm and Yarn 1 copy packages into each node_modules folder and hoist them: they move dependencies up to the root node_modules so they can be shared. pnpm does something different, in three layers.
package.json declares, so a phantom import of dayjs fails on your machine instead of in production, and each file is stored on disk once.- A content-addressed store. Every file of every package version is saved once, in a global store on the machine, under a name derived from a hash of its content. Ten projects using
react@19.1.0share one copy on disk. If two versions of a package share most files, the identical files are stored once. - A virtual store per repository.
node_modules/.pnpmcontains one folder per package version, filled with hard links (a second name for the same file on disk) into the global store. Each of these folders also gets symbolic links to exactly the dependencies that package declares. - Thin top-level folders. Each project's own
node_modulescontains only symbolic links to the packages it lists in itspackage.json. Nothing else.
That last layer is the strictness. With hoisting, booking can import dayjs even though only ui declared dayjs, because hoisting put dayjs at the root where Node.js finds it. That is a phantom dependency: code that works by accident.
A phantom dependency breaks the day ui drops dayjs, or when booking is built or deployed on its own. Under pnpm, booking cannot see dayjs unless it declares it, so the mistake fails on the developer's machine, not in production.
Since version 9.5, pnpm also offers catalogs: you name a third-party version once in pnpm-workspace.yaml (for example react: ^19.1.0) and each package writes "react": "catalog:". Upgrading React for every app becomes a one-line change. Since version 10, pnpm also skips dependencies' install scripts unless you list them as allowed, which blocks a common supply-chain attack.
Check your understanding · Exercise 3
apps/booking imports lodash-es, which only packages/ui lists as a dependency. Builds pass with npm. After the team switches to pnpm, booking fails with "Cannot find module". What is the right fix? Pick one.
Task runners: Turborepo, Nx, and affected runs
Workspaces link packages, but they know little about running tasks. pnpm -r run build runs build in every package, in dependency order, every time. That is fine with 5 packages and painful with 60. A task runner sits on top of the workspace, builds the task graph, and decides what actually needs to run.
Declaring how tasks relate
You describe each kind of task once, for the whole repository. Here is a Turborepo config. Nx expresses the same ideas in nx.json under targetDefaults, using the same ^build notation.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**"],
"env": ["API_URL"]
},
"test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}
^build means "the build task of every package I depend on". Without the caret, dependsOn points at a task in the same package.dependsOn: which tasks must finish first.^buildgives you the topological order: dependencies before dependents.inputs: which files can change the result. By default, all files tracked by Git in the package.outputs: which files the task produces, so the cache can save and restore them. Forget it and a hit restores logs but no files: "cache hit, butdistis empty".env: which environment variables can change the result (next chapter).cache: falseandpersistent: for dev servers and watchers, which never finish and must never be replayed from a cache.
Affected runs: only what a change can reach
The first big saving is to not even consider tasks that a change cannot reach. The tool asks Git which files changed compared with a base (usually main), maps each file to the package that contains it, then walks the graph backward to find every dependent. Only those projects are affected.
# Nx: test every project affected by the changes on this branch
nx affected -t test --base=origin/main
# Turborepo: the same idea
turbo run test --affected
# Turborepo filter syntax: ui plus everything that depends on it
turbo run build --filter=...@travel/ui
# Turborepo: packages changed since main, plus their dependents
turbo run test --filter=...[origin/main]
Try it on the lab below, which assumes inputs narrowed to source files as in the config above. Pick the package you changed and what changed in it, then switch between running everything, running only affected projects, and running everything with a warm cache. Watch which tasks run, which are skipped, and which are replayed from the cache.
5 of 8 projects run build and test (about 25 task-minutes), 0 replay from the cache, 3 are skipped.
Notice what the lab shows: affected runs and caching overlap but are not the same. Affected detection works at the level of whole projects and needs a base commit. Caching works at the level of single tasks and needs no base at all; it only asks "have I seen these exact inputs before?" Mature setups use both.
Choosing a task runner
Decision Which tool should orchestrate tasks in a JavaScript monorepo?
What we pick: Start with plain workspaces. Add Turborepo when CI or local builds get slow and you mainly want caching. Choose Nx when you also want enforced boundaries, generators, and help running CI across machines. Reach for Bazel only for large multi-language repositories with people to own the build. Feature sets change quickly, so check current docs before committing.
Check your understanding · Exercise 4
In turbo.json, test has "dependsOn": ["build"] (no caret). What does that mean? Pick one.
Task caching: never do the same work twice
Affected runs skip projects a change cannot reach. Caching goes further: it skips any task whose inputs are exactly the same as some earlier run, on any machine. It is the single biggest reason large monorepos stay fast.
How a task gets its fingerprint
Before running a task, the tool computes a hash: a short fingerprint, such as 8f3a91c2, that changes whenever anything that could change the result changes. The exact recipe differs by tool, but it is built from the same parts:
- the contents of the task's input files (by default, every file in the package that Git tracks)
- the hashes of the same task in the packages it depends on, so a change to
utilschanges the hash ofui#buildtoo - the task's own configuration, and the script it runs from
package.json - the resolved versions of its third-party dependencies, from the lockfile
- the values of environment variables you declared as inputs
- global inputs that affect everything, such as the root config files
The tool then looks the hash up. If it finds a saved result, it restores the output files (dist/, coverage/) and prints the saved logs, so the run looks the same as before. That is a cache hit, and it typically takes well under a second. If not, the task runs and its outputs and logs are saved under that hash for next time.
Local cache and remote cache
The local cache is a folder on your machine. It helps when you switch branches and back, but does nothing for CI, because each CI machine usually starts empty.
A remote cache is shared storage that every developer and every CI job reads from and writes to. Vercel offers one for Turborepo and Nx Cloud offers one for Nx; both tools can also use a self-hosted server. With a remote cache, a build a teammate ran an hour ago is a hit for you, and a CI job on main warms the cache for every branch that starts from it.
The dangerous case: a wrong hit
A cache miss only costs time. A wrong hit costs correctness: the tool replays an old output when the real result would have been different. It happens when a task reads something that is not part of its hash. The most common culprit is an environment variable.
Say booking#build bakes API_URL into the bundle, but nobody declared API_URL as an input. This can happen in Nx, or in Turborepo's older loose mode. CI builds for staging with API_URL=https://staging.api, and an hour later the production pipeline builds the same commit with API_URL=https://api.
The hash is identical, so the cache replays the staging bundle, and production users talk to the staging API. Use the lab below to see which edits change the hash and which do not.
Wrong hit: the bundle should change, but the hash did not, so the cache replays the old staging bundle.
- Declare every environment variable a task reads. Turborepo uses
envper task andglobalEnvfor all tasks; since version 2.0 its default strict mode hides undeclared variables from tasks, so a build cannot quietly depend on one. Nx lets you add{ "env": "API_URL" }to a task's inputs. - Keep tasks deterministic. No timestamps, random ids, or machine paths in outputs.
- Declare outputs fully. If a task writes
dist/andtypes/, list both, or a hit restores only half. - Do not cache tasks with side effects. Deploys, database migrations, and publishing should run every time.
The opposite mistake, inputs that are too broad, is safe but slow: a README.md edit should not rebuild anything. Narrow inputs so docs and test files do not invalidate builds; in Nx, a named input like production that excludes *.spec.ts files is the common pattern.
Check your understanding · Exercise 5
After turning on remote caching, a team sees a production bundle that contains a feature flag value from a preview build. Which fixes address the cause? Select all that apply.
Code ownership, boundaries, and dependency hygiene
In a polyrepo, the repository itself is a wall: the checkout team cannot change the search team's code without asking for access. In a monorepo anyone can open a pull request that touches anything. That is the point, but it needs three kinds of rules: who reviews what, who may import what, and which third-party versions are allowed.
CODEOWNERS: who must review a change
A CODEOWNERS file maps path patterns to people or teams. GitHub and GitLab both read it. When a pull request touches a matching path, the owners are added as reviewers automatically, and with branch protection turned on (on GitHub, "Require review from Code Owners"), the pull request cannot merge until an owner approves.
* line must come first. Give shared files like the lockfile and CI config to a platform team.- The last matching pattern wins on GitHub. Put the broad catch-all first and the specific folders after it, or the catch-all will override them.
- Own the shared files too. The root
package.json, the lockfile,turbo.jsonornx.json, and the CI config affect everyone. Give them to a platform team so one team cannot quietly change the build for all. - Owners should be teams, not people. People go on holiday; a team handle keeps working.
- A big change gets many owners. A rename across 30 apps may need 30 approvals, so allow a small group to approve mechanical codemods, or split the change per owner.
Boundaries: who may import what
Ownership controls who reviews a change, not what gets imported. Left alone, apps import each other's components, a UI package imports the API client, and the graph turns into a web where every change affects everything.
Boundary rules fail the lint step when an import crosses a line you drew. Nx tags each project, for example scope:booking and type:ui, and its @nx/enforce-module-boundaries ESLint rule allows only the arrows you list, such as "type:app may depend on type:ui and type:data; type:ui may depend only on type:ui and type:util". Outside Nx, eslint-plugin-boundaries or a dependency checker like dependency-cruiser does a similar job. The same rule should also stop one app from importing another app at all.
Dependency hygiene: one version policy
The same drift that hits internal packages in a polyrepo can hit third-party packages in a monorepo. If booking uses React 19.1 and admin uses React 18.3, then ui must work with both, two copies of React may end up in the lockfile, and a shared component can break with the confusing "invalid hook call" error when it meets the wrong copy.
Many teams adopt a one version policy: each third-party package appears in the repository at exactly one version. Google's monorepo is known for a strict rule like this. In JavaScript you can enforce it in a few ways:
- pnpm catalogs: declare
react: ^19.1.0once inpnpm-workspace.yaml; every package writes"react": "catalog:". - syncpack: a command-line tool that scans every
package.json, reports mismatched versions, and can fix them. Run its check in CI so mismatches fail the build. - Root-level overrides:
pnpm.overrides, npmoverrides, or Yarnresolutionsforce one version of a transitive dependency, useful for a security fix deep in the tree. - Peer dependencies for shared packages:
uilistsreactas a peer dependency, not a normal one, so it always uses the app's single copy.
The cost: upgrading React means upgrading every app at once. Teams handle it with codemods, a short exception list for stragglers, and grouped upgrade pull requests from Renovate or Dependabot that CI tests against every app.
Check your understanding · Exercise 7
A CODEOWNERS file has /packages/ui/ @travel/design-system on line 2 and * @travel/platform on line 9. Who is requested to review a change to packages/ui/Button.tsx on GitHub? Pick one.
Versioning internal packages
In a polyrepo, every shared package needs a version number, because that is the only way an app can say which code it wants. In a monorepo you get to choose. The choice shapes how changes flow, so make it on purpose.
Option 1: no versions at all (live at head)
Most monorepos keep internal packages "private": true and depend on them with workspace:*. Every app always uses the code at the current commit. There is no ui version 4 or 5; there is only ui on main. This is often called living at head.
The rule that makes it work: whoever changes a shared package fixes every caller in the same pull request. CI runs every affected app's tests, so a break is caught before merge, and nobody is ever left on an old version. This is the biggest practical benefit of a monorepo.
The cost shows up with breaking changes to widely used packages. Renaming a prop used in 400 places across 30 apps is a big pull request with many owners. Two techniques keep it manageable:
- Expand, then contract. Add the new API next to the old one. Move callers over in small pull requests, team by team. Delete the old API when nobody uses it.
- Codemods. A script (for example with jscodeshift or ts-morph) rewrites every caller mechanically, so the big pull request is generated, not hand-written.
Option 2: real versions, fixed or independent
Packages that leave the repository need real semver versions: an SDK for partners, an open-source component library, or a package another repository installs. There are two styles.
| Question | Fixed (single) versioning | Independent versioning |
|---|---|---|
| How it works | All published packages share one version and move together | Each package has its own version and moves only when it changes |
| Example | @travel/ui, @travel/icons, @travel/tokens all go from 5.2.0 to 5.3.0 | ui goes to 5.3.0 while icons stays at 2.1.4 |
| Good for | Packages always used together, so users never mix versions | Packages used on their own, with their own users and pace |
| Downside | Releases with no changes to a package still bump it | Users must work out which versions work together |
Changesets: recording intent with each change
Changesets is the most common release tool for JavaScript monorepos. Its idea is simple: the person who makes a change knows best whether it is a patch, a minor, or a major change, so they record that in the same pull request, as a small Markdown file.
---
"@travel/ui": minor
"@travel/tokens": patch
---
Button: add a `loading` prop that shows a spinner and sets aria-busy.
.changeset/, created by running npx changeset. The text becomes the changelog entry.changeset version wrote.At release time, changeset version reads all pending files, picks the highest bump for each package, and updates versions and CHANGELOG.md files. It also bumps packages that depend on a changed package when their dependency range needs updating. changeset publish then publishes what changed.
The official GitHub Action automates this with a standing "Version Packages" pull request: merging it releases. Config options fixed and linked give you fixed or grouped versioning for chosen packages. The design systems lesson walks through this flow for a component library.
Check your understanding · Exercise 8
A monorepo has 8 private internal packages and 1 SDK published to npm for partners. What versioning setup fits best? Pick one.
CI at scale and what breaks
A monorepo's health shows up in one number: how long a typical pull request waits for CI. Here is how the earlier tools combine for the travel company at 12 apps and 40 packages. The numbers are illustrative; the shape is typical.
- Run everything, no cache: about 38 minutes. Every task for every project, on every pull request.
- Affected only: about 11 minutes. A typical change touches one package and its dependents. Changes to widely used packages still run most of the graph.
- Affected plus a warm remote cache: about 4 minutes. Affected tasks whose inputs match an earlier run, often on
mainor a previous push, are replayed. - Plus spreading tasks across machines: about 2 minutes. Nx and some CI systems split the remaining tasks across several machines, which helps when they are many and independent.
Watch the cache hit rate
The cache hit rate is the share of tasks answered from the cache. Track it per task type over time, from the tool's run summaries (turbo run --summarize, or the Nx Cloud dashboard). A healthy repo usually sees most lint and test tasks hit on a typical pull request. A sudden drop means something entered every hash, for example:
- a root file listed as a global input that changes in every commit, such as a generated version file
- an environment variable that differs on every CI run, such as the build number or commit time, declared as a task input
- a lockfile that churns because bots update dependencies daily
- a non-deterministic build writing a timestamp into output that a later task hashes
To find the culprit, diff the hashed inputs of two runs of the same task: Turborepo's --dry=json output and Nx Cloud's run details both list them.
Failure modes and how you notice them
| Symptom | Likely cause | Fix |
|---|---|---|
| Affected runs test everything on every pull request | A hub package (utils, common) that everything depends on changes constantly | Split the hub into small packages; move app-specific code out of it |
Affected runs test nothing, then main breaks | CI checked out with shallow history, so the base commit was missing | Fetch enough history (or the base branch) before computing affected projects |
| Production has a staging value | Undeclared environment variable caused a wrong cache hit | Declare it as an input; use strict environment mode |
| Cache hit but the app shows old code | outputs missing a folder, or a task writes outside its package | Declare all outputs; keep tasks inside their own folder |
| Clone takes 10 minutes | Years of history and large binary files | Partial clone, sparse checkout, Git LFS for binaries |
| A flaky test blocks every team | One shared test suite runs for many changes | Quarantine flaky tests with owners; retry only known-flaky ones |
Git at scale
Git itself slows down in very large repositories, though most frontend monorepos never get there. Partial clone (git clone --filter=blob:none) downloads file contents only when needed. Sparse checkout (git sparse-checkout set apps/booking packages) puts only some folders on disk. Microsoft's scalar tool bundles these settings.
Check your understanding · Exercise 9
The cache hit rate for test dropped from about 80% to under 5% overnight, and nobody changed any test code. What is the most likely cause? Pick one.
Choosing a monorepo, moving in, and the interview
A monorepo is not automatically better. It trades independence for consistency, and it asks for investment in tooling. The question is whether your projects share enough code, and change it often enough, for that trade to pay off.
| Question | Monorepo | Polyrepo |
|---|---|---|
| Change shared code and its callers | One pull request, tested together | Publish, then one upgrade pull request per repo |
| Versions of shared code in use | One: the current commit | Many, drifting apart |
| Tooling and config | Shared once, changed once | Copied, drifting apart |
| Team independence | Lower: shared CI, shared rules, shared upgrades | Higher: each repo chooses its own pace |
| Access control | Per folder through review rules; read access is usually all or nothing | Per repository, including read access |
| CI cost without investment | Grows with the whole repo | Small per repo |
Large monorepos in the wild
The famous examples are huge. Google described its single repository in a 2016 paper by Rachel Potvin and Josh Levenberg: billions of lines of code, tens of thousands of engineers, a custom version control system called Piper, and a build system called Blaze, whose open-source version is Bazel.
Meta keeps most of its code in a few very large repositories and open-sourced its Git-compatible client, Sapling, in 2022. Microsoft moved the Windows codebase to Git and built a virtual file system and the scalar tool to make it workable. These numbers are mostly self-reported and change over time, so treat them as orders of magnitude.
The lesson is not "put everything in one repo" but that these companies built serious tooling to make it work. Open-source projects such as Babel, React, Next.js, and Vite use smaller workspace monorepos, closer to what most frontend teams need.
Migrating into a monorepo
Move in steps, keep every app deployable throughout, and start with the most connected projects: the shared packages and their heaviest users.
- Set up the shell. An empty repository with workspaces, the task runner, shared
tsconfigand ESLint packages, CI with affected runs, and aCODEOWNERSfile. - Import with history. Rewrite each old repository so its files live under the target folder (for example with
git filter-repo --to-subdirectory-filter packages/ui), then merge it in.git logandgit blamekeep working. - Switch links. Change each app's dependency on the published
@travel/uitoworkspace:*. Fix the breakages that show up: these are the version drift you already had, now visible. - Align third-party versions. Run syncpack or set pnpm catalogs, and upgrade the stragglers. Expect this to be the slowest step.
- Move deploys. Point each app's deploy pipeline at its folder, with a filter so a deploy runs only when that app is affected.
- Archive the old repositories as read-only, with a README pointing to the new location.
Using it in a system design interview
Monorepos come up when the design has several apps or teams sharing code: a design system, a micro-frontend platform, web plus mobile web, or a customer app plus an admin tool. Bring it up when you discuss how the codebase is organized, not as the first thing.
- Draw the package graph: apps on top, shared packages below, arrows down. Mark which packages are published and which are internal.
- Say how changes flow: internal packages live at head with
workspace:*; published packages use changesets. - Say how CI stays fast: affected runs, a remote cache with declared inputs, and a graph kept shallow by boundary rules.
- Name a trade-off: shared upgrades and shared CI reduce team independence; micro-frontends with separate repos are the other end of that scale.
By level: a junior answer explains workspaces and why one pull request beats five. A senior answer adds caching, affected runs, ownership, and versioning. A staff answer adds graph shape, cache safety, CI metrics, and migration.
Think first
Your company is moving to micro-frontends so that five teams can deploy on their own. Someone says that means you must split the monorepo into five repositories. Do you agree?
No. Deploy independence and repository layout are separate choices. Each micro-frontend can be its own app in the monorepo with its own pipeline, filtered to run only when that app is affected.
The monorepo then keeps shared code consistent: one design system version, one shared contract for the shell, and type-checked integration between them. The cost is shared CI and shared upgrades; split repos make sense only if teams truly need different tooling or strict access separation.
Summary
- A monorepo is one repository holding many projects. It is not a monolith: apps still build and deploy separately.
- The dependency graph is the core idea. Dependencies say what must run first; dependents say what a change can break.
- Workspaces link local packages without publishing. pnpm's content-addressed store saves disk, and its strict layout catches phantom dependencies.
- Task runners like Turborepo and Nx use the graph to order tasks, run them in parallel, run only affected projects, and replay cached results.
- A cache is only as safe as its declared inputs. Declare environment variables and outputs, and keep builds deterministic.
- Internal packages usually live at head with
workspace:*; only published packages need versions, typically managed with changesets. CODEOWNERS, boundary lint rules, and a one version policy keep a shared repository from turning into a tangle.- Keep the graph shallow and avoid hub packages: graph shape decides CI cost more than any tool.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
CI takes 40 minutes on every pull request in a 60-package monorepo. How would you bring it down?
First measure: which tasks take the time, and what share of tasks run per pull request. Then add the layers in order. Run only affected projects against the base branch. Add a remote cache shared by CI and developers, with every task's inputs, outputs, and environment variables declared so hits are safe. Then spread the remaining tasks across several CI machines.
Finally look at the graph. If a hub package like utils changes in most pull requests, everything is always affected; split it. Track CI time and cache hit rate per task over time so regressions are visible.
Question 2
How does a task cache decide that it can skip a build, and how can that go wrong?
It hashes everything that could change the result: input files, the same task's hashes in dependencies, the task config and script, lockfile versions, declared environment variables, and global inputs. If a stored result has that hash, it restores the outputs and logs instead of running.
It goes wrong when a task reads something outside the hash, usually an undeclared environment variable, so different builds share one hash and the cache replays the wrong output. Incomplete outputs and non-deterministic builds also cause trouble. Fix with declared inputs, strict environment mode, and complete outputs.
Question 3
How do you stop the booking app from importing the admin app's internal components?
Make the right path the only path. Shared code moves into a package with a narrow exports field. Boundary lint rules, such as Nx tags with @nx/enforce-module-boundaries or eslint-plugin-boundaries, fail CI when an app imports another app. CODEOWNERS makes the admin team review any change to their folders.
Question 4
Should internal packages in a monorepo have version numbers?
Usually not. Private packages consumed with workspace:* live at head: every app uses the current code and the author of a change fixes all callers in the same pull request, tested by CI. That removes upgrade work and version drift.
Packages that leave the repository, like a partner SDK or an open-source library, need semver. Use changesets so each pull request records its bump level, and choose fixed versioning for packages always used together or independent versioning for packages used on their own.
Question 5
When would you not use a monorepo?
When projects share little code, use different languages and toolchains, and release on unrelated schedules, the coordination costs buy you nothing. Also when read access must be strictly separated, since most Git hosts grant read access per repository. And when nobody can invest in CI tooling: without affected runs and caching, CI grows with the whole repository.
Practice it
These design problems lean on monorepos.
References
- pnpm: Workspace The workspace protocol and how pnpm links local packages.
- pnpm: Motivation The content-addressed store and the non-flat node_modules layout.
- Turborepo documentation Task configuration, caching, and filtering.
- Nx: Mental model Project graph, task graph, affected, and computation caching in Nx.
- About code owners (GitHub Docs) CODEOWNERS syntax, location, and precedence.
- Changesets Recording version bumps per change and releasing monorepo packages.
- Why Google Stores Billions of Lines of Code in a Single Repository Potvin and Levenberg's 2016 paper on Google's monorepo.
Read next
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Micro-frontendsSplitting one web app into parts that separate teams build and deploy on their own, then combining them at build time, on the server, or in the browser: how each approach works, what it costs in bytes and complexity, and why most teams should not do it.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.