cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

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-kit 3, 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.
Left, polyrepo: five repositories. The booking app uses ui 4.3, the account app uses ui 4.1, the admin app is stuck on ui 3.9, the ui-kit repository publishes 4.3, and the api-client repository publishes 2.0. That is five repos, three versions of ui in use, and five CI setups. Right, monorepo: one repository with apps booking, account, and admin, and packages ui and api-client. Every app uses the current ui: one repo, one version, one CI setup.polyrepobooking: uses ui 4.3account: uses ui 4.1admin: uses ui 3.9ui-kit: publishes 4.3api-client: publishes 2.05 repos, 3 versions of uimonorepoone repoapps/bookingaccountadminpackages/uiapi-client1 repo, 1 version of ui
With separate repositories, every app upgrades shared code on its own schedule, so versions drift apart. In one repository, every app uses the code at the current commit.

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/booking import packages/ui without 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: a CODEOWNERS file 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.

A dependency graph in three rows. Top row, apps: booking, account, admin, and a docs site. Middle row, shared packages: ui and api-client. Bottom row, base packages: tokens and utils. booking, account, and admin each depend on ui and api-client. docs depends on ui only. ui depends on tokens and utils. api-client depends on utils.appsbookingaccountadmindocsshareduiapi-clientbasetokensutils
Arrows point from a project to what it depends on. Read them forward to see what must build first; read them backward to see what a change can break. A change to utils reaches every app.

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). booking depends on ui and api-client, and through them on tokens and utils. To build booking, those must be built first. This direction answers "what must run before me?"
  • Dependents (follow the arrows backward). utils is used by ui and api-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:

  1. Correct order. tokens#build runs before ui#build, which runs before booking#build. Nobody has to write that order by hand in a CI script.
  2. Parallelism. Tasks with no path between them run at the same time. utils#build and tokens#build do 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.
  3. Skipping. If a change touched only api-client, then ui and tokens cannot 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.

yaml
# 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"
# }
pnpm lists workspace folders in its own YAML file; npm, Yarn, and Bun use a 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.

Three layers of pnpm's layout. Top, apps/booking/node_modules holds only links to what booking declares: @travel/ui, a link to the packages/ui folder, and react, a symlink. dayjs is not there because booking never declared it. Middle, node_modules/.pnpm holds one folder per package version, such as react@19.1.0 and dayjs@1.11.13. Bottom, the global store keeps every file once, named by a hash of its content; the middle layer's files are hard links into it.apps/booking/node_modules@travel/uilink to packages/uireactsymlinkdayjsnever declaredsymlinknode_modules/.pnpmreact@19.1.0dayjs@1.11.13hard linksglobal storeby hasha91f3c07e5d277b019cc
Each layer only points down. A project sees exactly what its 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.
  1. 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.0 share one copy on disk. If two versions of a package share most files, the identical files are stored once.
  2. A virtual store per repository. node_modules/.pnpm contains 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.
  3. Thin top-level folders. Each project's own node_modules contains only symbolic links to the packages it lists in its package.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.

json
{
  "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 }
  }
}
turbo.json. The caret in ^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. ^build gives 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, but dist is empty".
  • env: which environment variables can change the result (next chapter).
  • cache: false and persistent: 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.

bash
# 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]
Both tools compare against a base commit. In CI, make sure enough Git history is fetched for that comparison to work.

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.

Changed package:
What changed:
CI runs:
A repo graph: booking, account, and admin depend on ui and api-client; docs depends on ui; ui depends on tokens and utils; api-client depends on utils. ui changed (a source file), CI mode: affected only, no cache. booking: ran, account: ran, admin: ran, docs: ran, ui: ran, api-client: skipped, tokens: skipped, utils: skipped. 5 of 8 projects run build and test (about 25 task-minutes), 0 replay from the cache, 3 are skipped.bookingran, 7 minaccountran, 6 minadminran, 5 mindocsran, 3 minui *ran, 4 minapi-clientskippedtokensskippedutilsskipped
Hatched boxes run their build and test tasks, green boxes replay from the cache, faded boxes are skipped. Affected detection works on whole projects from the changed files; the cache works on each task's exact inputs.

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?

Plain workspace scripts

pnpm -r run build or npm run build --workspaces.

pnpm can filter by changed packages with --filter "...[origin/main]".

Pros

  • Nothing extra to install or learn.
  • Fine for a handful of packages.

Cons

  • No task cache.
  • CI time grows with every new package.

Turborepo

A task runner written in Rust, configured with one turbo.json.

Sits on top of your existing workspaces and scripts.

Pros

  • Small setup: most repos add it in an afternoon.
  • Local and remote caching, affected runs, parallel tasks.

Cons

  • Fewer extras than Nx: simple generators (turbo gen) and an experimental boundaries check, but no plugin system.
  • JavaScript and TypeScript focus.

Nx

A task runner plus a plugin system, configured in nx.json and per-project files or inferred from tool configs.

Ships a graph viewer, code generators, and a module boundary lint rule.

Pros

  • Rich tooling for large repos: boundaries, generators, migrations that update config between versions.
  • Can split tasks across many CI machines with its hosted Nx Cloud service.

Cons

  • More concepts to learn and more config to own.
  • Plugins can hide what actually runs.

Bazel (or similar strict build systems)

Every build step declares its exact inputs and outputs in BUILD files, and runs in a sandbox.

Pros

  • Builds are reproducible and cacheable across many languages.
  • Scales to the very largest repositories.

Cons

  • Steep learning curve; usually needs a team to maintain the build.
  • JavaScript tooling fits it less naturally.

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 utils changes the hash of ui#build too
  • 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.

A flowchart. First, compute the task's hash, for example 8f3a91c2. Is it in the local cache? If yes, restore the outputs and logs in under a second. If no, is it in the remote cache? If yes, fetch and restore in a few seconds. If no, run the task, which can take minutes, then save its outputs to the local and remote caches.compute hash8f3a91c2in local cache?restoreunder 1 sin remote cache?fetch, restorea few secondsrun the taskseconds to minutessave outputslocal and remoteyesnoyesno
The tool tries the cheapest answer first. Only a hash nobody has seen before, on any machine, costs a real run, and that run then makes the hash a hit for everyone.

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.

Since the last run:
booking#build declares API_URL in env:
The inputs of booking#build, last run and this run. src/** files: hashed, unchanged; ui#build hash: hashed, unchanged; lockfile versions: hashed, unchanged; API_URL: not hashed, changed; README.md: not hashed, unchanged. Task hash last run 67d58d, this run 67d58d. Wrong hit: the bundle should change, but the hash did not, so the cache replays the old staging bundle.last runthis runsrc/** filessrc-1src-1ui#build hashui-1ui-1lockfile versionslock-1lock-1API_URL (not hashed)stagingprodREADME.md (not hashed)readme-1readme-1task hash67d58d67d58dwrong hit: old bundle replayed
Only hashed inputs can change the task hash. A changed input that is not hashed either does not matter (the README) or causes a wrong hit (an undeclared environment variable).

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 env per task and globalEnv for 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/ and types/, 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.

Shared packages: UI, config, and source versus built

Most frontend monorepos end up with the same handful of shared packages. Each one is a normal package with a package.json and a name, so imports read like any library: import { Button } from "@travel/ui".

PackageWhat it holdsTypical users
uiThe design system: components, styles, iconsEvery app, plus a Storybook docs app
tokensColors, spacing, and type scales as CSS variables and TypeScript constantsui and any app with custom styling
api-clientTyped fetch functions, often generated from an OpenAPI or GraphQL schemaEvery app that calls the backend
utilsSmall pure helpers: dates, money, validationAlmost everything (keep it small, see chapter 2)
tsconfig, eslint-configShared settings for TypeScript and ESLintEvery package, as dev dependencies

Config packages

Instead of 12 copies of tsconfig.json drifting apart, each package extends one shared base. Turning on a stricter compiler option becomes one change that CI checks everywhere.

json
// packages/tsconfig/base.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true
  }
}

// apps/booking/tsconfig.json
{
  "extends": "@travel/tsconfig/base.json",
  "include": ["src"]
}
The app lists @travel/tsconfig as a dev dependency with workspace:*, then extends it like any published config.

ESLint works the same way: an eslint-config package exports a flat config array, and each package's eslint.config.js imports and spreads it, adding local rules if needed.

A small public surface with exports

Every shared package should say exactly what callers may import, using the exports field of its package.json. Anything not listed there cannot be imported by name. Without it, apps reach into deep paths like @travel/ui/src/button/internal/useRipple, and every refactor inside ui breaks someone. The component API lesson covers how to design the surface itself.

Consume as source, or build first?

The biggest design choice for an internal package is whether apps consume its source (the TypeScript files) or a built copy (compiled JavaScript in dist/). Both are common; they trade speed of editing against speed and isolation of builds.

Top, as source: the files in ui/src, written in TypeScript, go straight into the booking app's bundler, which compiles ui along with the app, and every other app compiles ui again. Bottom, built: ui/src goes through its own ui#build task, which the cache can skip, producing ui/dist with JavaScript and type files, which the booking bundler then reads as plain JavaScript.as sourceui/src.tsx filesbooking bundlercompiles ui toobundleevery app compiles ui againbuiltui/src.tsx filesui#buildcachedui/dist.js + .d.tsbundlerplain JS inui compiles once; a cache hit when unchanged
As source, there is no extra step and edits show up at once, but each app compiles the package again. Built, the package compiles once into dist, and that build is a task the cache can skip.

Decision How should apps consume an internal package?

As source (just-in-time)

exports points straight at ./src/index.ts.

Each app's bundler (Vite, webpack, Next.js) compiles the package's files as part of the app.

Pros

  • No build step for the package: edit a component and the app hot-reloads it at once.
  • Go-to-definition jumps straight to real source.

Cons

  • Every app must support the package's syntax and settings (JSX transform, path aliases, CSS handling).
  • The package's code is compiled again inside each app, so it cannot be cached on its own and type checking gets slower as the repo grows.

Built (compiled package)

The package has its own build task (tsc, tsup, or Vite library mode) writing to dist/.

exports points at ./dist/index.js and ./dist/index.d.ts.

Pros

  • Its build is a separate task, so the cache can skip it when it has not changed.
  • Apps get plain JavaScript and do not need to know how the package is built.

Cons

  • A dev loop needs a watcher, or edits do not show up.
  • Go-to-definition lands in .d.ts files unless you ship declaration maps.

Published

A built package that is also released to a registry with real version numbers, for users outside the repo.

Pros

  • Works for other repositories, partners, and open source users.

Cons

  • Needs versioning, changelogs, and care about breaking changes (see the versioning chapter).

What we pick: Start internal packages as source: it is the simplest setup and the fastest loop. Switch a package to a built one when it grows large, when type checking or app builds slow down, or when several apps use different bundlers. Publish only what leaves the repository.

Check your understanding · Exercise 6

Apps consume @travel/ui as source. The team adds a fourth app that uses a different bundler, and it fails to compile ui's CSS imports. What are reasonable fixes? 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.

A CODEOWNERS file with four lines. Line 1, star, owned by the platform team: the catch-all, which goes first. Line 2, the packages/ui folder, owned by the design system team: later lines win, so it overrides the catch-all. Line 3, the apps/booking folder, owned by the booking team: one team per app. Line 4, the pnpm lockfile, owned by the platform team: shared build files get an owner too..github/CODEOWNERS* @travel/platformcatch-all goes first/packages/ui/ @travel/design-systemlater lines win/apps/booking/ @travel/bookingone team per app/pnpm-lock.yaml @travel/platformshared build filesGitHub: the last matching pattern wins
On GitHub the last matching line decides the owners, so the broad * 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.json or nx.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.0 once in pnpm-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, npm overrides, or Yarn resolutions force one version of a transitive dependency, useful for a security fix deep in the tree.
  • Peer dependencies for shared packages: ui lists react as 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:

  1. 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.
  2. 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.

QuestionFixed (single) versioningIndependent versioning
How it worksAll published packages share one version and move togetherEach 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.0ui goes to 5.3.0 while icons stays at 2.1.4
Good forPackages always used together, so users never mix versionsPackages used on their own, with their own users and pace
DownsideReleases with no changes to a package still bump itUsers 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.

markdown
---
"@travel/ui": minor
"@travel/tokens": patch
---

Button: add a `loading` prop that shows a spinner and sets aria-busy.
A file in .changeset/, created by running npx changeset. The text becomes the changelog entry.
A sequence diagram with four actors: developer, pull request, release bot, and npm. The developer opens a pull request with the code change and a changeset file. The pull request is merged to main, which triggers the release bot. The bot runs changeset version and opens a Version Packages pull request with bumped versions and changelogs. The developer merges it. The bot runs changeset publish to npm, and ui 5.3.0 is live.developerpull requestrelease botnpmcode + changesetmerged to mainVersion Packagesmerges itchangeset publishui@5.3.0 is live
The bump level is decided by the person who made the change, in the same pull request. The release itself is just merging a pull request that 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.

A bar chart of CI minutes for one typical pull request in a repository with 12 apps and 40 packages, drawn to scale. Running everything with no cache: 38 minutes. Affected projects only: 11 minutes. Affected plus a warm remote cache: 4 minutes. Adding tasks split over 4 machines: 2 minutes. The numbers are illustrative.run everything, no cache38 minaffected projects only11 minaffected + warm remote cache4 min+ tasks split over 4 machines2 min
Each layer removes a different kind of waste: affected runs skip projects a change cannot reach, the cache skips tasks with known inputs, and splitting runs the rest in parallel. Illustrative numbers; measure your own.
  • 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 main or 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

SymptomLikely causeFix
Affected runs test everything on every pull requestA hub package (utils, common) that everything depends on changes constantlySplit the hub into small packages; move app-specific code out of it
Affected runs test nothing, then main breaksCI checked out with shallow history, so the base commit was missingFetch enough history (or the base branch) before computing affected projects
Production has a staging valueUndeclared environment variable caused a wrong cache hitDeclare it as an input; use strict environment mode
Cache hit but the app shows old codeoutputs missing a folder, or a task writes outside its packageDeclare all outputs; keep tasks inside their own folder
Clone takes 10 minutesYears of history and large binary filesPartial clone, sparse checkout, Git LFS for binaries
A flaky test blocks every teamOne shared test suite runs for many changesQuarantine 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.

QuestionMonorepoPolyrepo
Change shared code and its callersOne pull request, tested togetherPublish, then one upgrade pull request per repo
Versions of shared code in useOne: the current commitMany, drifting apart
Tooling and configShared once, changed onceCopied, drifting apart
Team independenceLower: shared CI, shared rules, shared upgradesHigher: each repo chooses its own pace
Access controlPer folder through review rules; read access is usually all or nothingPer repository, including read access
CI cost without investmentGrows with the whole repoSmall 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.

A decision tree. Question 1: do the projects share code and change it often? If no, a polyrepo is fine. If yes, question 2: do you need to split read access, for example for contractors? If yes, keep that part in a separate repository; the rest can be a monorepo. If no, question 3: can you own CI tooling such as caching and affected runs? If yes, use a monorepo with workspaces and a task runner. If no, start with workspaces only and add a task runner when CI slows down.share code often?between projectspolyrepo is finesplit read access?for example, contractorskeep that part apartthe rest can be a monorepocan you own CI tools?caching, affected runsmonorepoworkspaces + task runnerworkspaces only, for nowadd a runner when CI slowsnoyesyesnoyesno
Most frontend teams with several apps sharing a design system end at the green monorepo box. The questions that push you away are little shared code and hard access rules.

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.

  1. Set up the shell. An empty repository with workspaces, the task runner, shared tsconfig and ESLint packages, CI with affected runs, and a CODEOWNERS file.
  2. 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 log and git blame keep working.
  3. Switch links. Change each app's dependency on the published @travel/ui to workspace:*. Fix the breakages that show up: these are the version drift you already had, now visible.
  4. Align third-party versions. Run syncpack or set pnpm catalogs, and upgrade the stragglers. Expect this to be the slowest step.
  5. Move deploys. Point each app's deploy pipeline at its folder, with a filter so a deploy runs only when that app is affected.
  6. 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?

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?

Question 2

How does a task cache decide that it can skip a build, and how can that go wrong?

Question 3

How do you stop the booking app from importing the admin app's internal components?

Question 4

Should internal packages in a monorepo have version numbers?

Question 5

When would you not use a monorepo?

Practice it

These design problems lean on monorepos.

References

Read next