Design a frontend monorepo
The method applied to the codebase itself: several apps sharing UI and config, the tools compared, and deep dives on affected builds and task caching, module boundaries, dependency and release policy, and CI time as the repo grows, with live demos.
"We have a marketing site, a web app and an admin tool. They share a UI kit, an API client and a pile of config. Design the repository they live in." This prompt has no screen to draw. The product is the codebase, the users are the engineers, and the non-functional requirements are CI time, ownership and safe releases. It still fits the design method: scope it, sketch the architecture, then go deep on the parts that decide whether a monorepo stays fast and untangled as it grows.
Every demo below works on the same sample repo: ten projects in four layers plus shared config. The tools are compared, not taught. The ideas carry over to whichever one you pick.
Requirements and architecture
Start with who uses the repo and what they need from it. Then draw the projects and the layer that runs them.
- Functional: share code between apps without publishing it first; change a shared package and update every consumer in the same pull request; build, test and lint each project on its own; publish the few packages other repos use.
- Non-functional: CI time per pull request grows with the size of the change, not the size of the repo; every directory has an owner who reviews changes to it; a release never ships a package against a dependency it wasn't tested with; a new engineer can run one app on day one.
- Scope cuts: how many apps and teams (three and five here), whether anything is published outside the repo (three packages are), and whether it's JavaScript only (it is; a multi-language repo changes the tooling).
- Architecture: apps on top, then feature packages, then UI and data, then utilities, with config every project shares. Under them sit two pieces of tooling: the package manager's workspace (one install, local packages linked) and a task runner (task graph, cache, affected runs). Name the alternatives: a polyrepo, or micro frontends, which is a deploy strategy and works with either repo shape.
Try it: the sample repo and its alternatives
Monorepo
Every app and package in one repository, one install, one task runner.
- Wins
- A change to a shared package and all its consumers lands in one reviewed commit; one toolchain; refactors across apps are atomic.
- Costs
- Needs a task graph, caching and affected runs to keep CI fast, plus boundary rules and CODEOWNERS so it doesn't become one tangled app.
Polyrepo
A repository per app or package; shared code is published and consumed by version.
- Wins
- Hard boundaries and independent pipelines for free; each team owns its repo end to end.
- Costs
- A change to shared code means publish, then bump every consumer repo; versions drift and toolchains diverge.
Micro-frontends
Separately built and deployed apps composed into one page at runtime.
- Wins
- Teams deploy on their own schedule, even with different frameworks.
- Costs
- Runtime integration, duplicated dependencies in the browser and a shared-state contract. It's a deploy strategy and can live in either repo shape.
Do: Lay projects out in layers that only depend downward, and decide early what gets published and what stays internal.
Avoid: Moving every repo into one folder and calling it a monorepo, with no task graph, no cache and no boundaries.
Read more:monorepo.tools(opens in a new tab)Why Google stores billions of lines in one repo(opens in a new tab)The design method(opens in a new tab)
Choosing the tooling
Monorepo tooling comes in three layers, and most repos combine one tool from each: a package manager that installs and links, a task runner that orders, caches and filters tasks, and a release tool that bumps versions and writes changelogs.
- Package managers (pnpm, npm, Yarn) give you workspaces. pnpm adds the
workspace:protocol, strictnode_modulesand catalogs for shared versions. None of them caches tasks. - Task runners (Turborepo, Nx) read the dependency graph, hash each task's inputs, and skip or restore what they've seen before, locally and from a shared cache. Nx also ships boundary rules, generators and its own release command. Turborepo keeps a smaller core and points to Changesets for releases.
- Pick by what you need, not by popularity. Tick your needs in the demo to see which common stacks cover them.
Try it: which tools cover your needs
Common stacks against your list
pnpm workspaces aloneNo
Gaps: task graph (partly), shared (remote) cache (no), affected runs (partly).
pnpm + Turborepo + ChangesetsYes
Covers everything you picked.
pnpm + NxYes
Covers everything you picked.
npm workspaces + ChangesetsNo
Gaps: workspaces and linking (partly), task graph (no), shared (remote) cache (no), affected runs (no).
| Capability | pnpm workspacesPackage manager · 12.14 | npm workspacesPackage manager · 12.2 | TurborepoTask runner · 2.11 | NxTask runner + plugins · 23.3 | ChangesetsRelease tool · 3.0 |
|---|---|---|---|---|---|
| Workspaces and linking | Yesworkspace: protocol, rewritten to a real version on publish; catalogs for shared versions | PartlySymlinks workspaces into node_modules; no workspace: protocol | NoUses the package manager's workspaces | PartlyUses package-manager workspaces or TypeScript path aliases | NoNot its job |
| Task graph | Partlypnpm -r runs scripts in topological order, with no task-level dependencies | No--workspaces runs in package.json order, not dependency order | YesdependsOn: ["^build"] runs dependencies first | YesdependsOn per target or in targetDefaults | NoNot its job |
| Local task cache | NoNot its job | NoNot its job | YesHashes inputs; a hit restores outputs and replays logs | YesHashes inputs; a hit restores outputs and replays logs | NoNot its job |
| Shared (remote) cache | NoNot its job | NoNot its job | YesVercel Remote Cache, or self-host the open Remote Caching API | YesNx Cloud, or a self-hosted server built to its OpenAPI spec | NoNot its job |
| Affected runs | Partly--filter "...[origin/main]" selects changed packages and their dependents | NoNot its job | Yes--affected (changed packages and their dependents vs main) | Yesnx affected -t <task> with --base/--head | NoNot its job |
| Boundary rules | NoStrict node_modules blocks undeclared dependencies, not layer rules | NoNot its job | Partlyturbo boundaries is experimental: undeclared imports, tag allow/deny rules | Yesenforce-module-boundaries lint rule with project tags | NoNot its job |
| Code generators | NoNot its job | Partlynpm init -w scaffolds an empty workspace | Yesturbo gen workspace, plus custom generators | Yesnx g, from plugins or local generators | NoNot its job |
| Versioning and release | NoPublishes (pnpm -r publish) but doesn't bump versions | NoNot its job | NoIts docs point to Changesets | Yesnx release: version, changelog and publish | YesChange files → version bumps, dependent bumps and changelogs; fixed and linked groups |
One snapshot, and tools move fast: check their docs before you choose. Also worth knowing: Rush (installs, builds and publishes, from Microsoft), Lerna (now maintained by the Nx team and runs tasks through Nx) and Bazel (a multi-language build system with remote execution, heavy for a JavaScript-only repo).
Do: Choose one tool per layer from a written list of needs, and pin their versions in the repo.
Avoid: Two task runners in one repo, or a task runner doing a package manager's job.
Read more:pnpm: workspaces(opens in a new tab)Turborepo docs(opens in a new tab)Nx docs(opens in a new tab)Changesets(opens in a new tab)
Deep dive: the affected graph and caching
Two ideas make a big monorepo fast. Affected: a change can only break the projects that depend on it, so run only those. Caching: a task with the same inputs gives the same outputs, so hash the inputs and reuse the result when the hash matches.
- The task graph orders the work. A project's build waits for its dependencies' builds (
^build); tasks with no path between them run in parallel. - A task's hash covers its own files, its config, the lockfile entries and env vars it uses, and its dependencies' hashes. On a hit the runner restores the saved outputs and replays the logs instead of running the task.
- Affected is coarse and the cache is precise. Change the ESLint config and every project is affected, but only lint misses the cache. Revert a change and the old hashes hit again.
- A local cache only helps the machine that filled it. A shared (remote) cache lets CI reuse your laptop's builds and lets every pull request reuse main's.
Try it: change a package, run a task, run it again
changed affected (depends on a change) unaffected
6 of 8 projects with tasks are affected.
No run yet. Run a task, run it again, then change a package or switch machine.
Do: Declare every task's outputs and the env vars it reads, so cache keys are complete, and share the cache with CI.
Avoid: Tasks that read undeclared env vars or the clock: the cache will happily restore a stale result.
Read more:Turborepo: caching(opens in a new tab)Nx: how caching works(opens in a new tab)Nx: affected(opens in a new tab)
Deep dive: boundaries and ownership
Without rules, a monorepo turns into one big app: anything can import anything, and every change affects everything. Boundaries keep the graph shaped the way you drew it, and ownership tells you who has to agree to a change.
- Layer rules: each kind of project may only import the kinds below it. Tag projects (by type, and often by domain or team) and let a lint rule reject imports that break the rules.
- Public entry points: a package's
exportsfield is its API. A deep import intosrc/couples you to files its owners think are private. - No cycles: two packages that import each other can't be built in any order, and neither can change without the other.
- Ownership: a CODEOWNERS file maps paths to teams (the last matching pattern wins), and branch protection requires one of them to approve. Internal packages can change in lockstep with their consumers; published ones need versions and a changelog because other repos depend on them.
Try it: try an import between two projects
apps/web/src/index.ts: import { … } from "@acme/billing"
Allowed
Review needed from @acme/web-team (CODEOWNERS for apps/web/).
One example rule set: apps → features → UI or data → utilities, each layer importing only downward (features may also import features). Teams also tag projects by domain or team, and some forbid feature-to-feature imports entirely.
Do: Enforce layer and entry-point rules in lint and CI from the first week, and give every directory an owner.
Avoid: Boundaries that live only in a wiki, or deep imports "just this once".
Read more:Nx: enforce module boundaries(opens in a new tab)Turborepo: boundaries (experimental)(opens in a new tab)GitHub: about code owners(opens in a new tab)
Deep dive: dependencies and versioning
Shared code makes dependency mistakes everyone's problem. Three decisions matter: one version of each library or many, whether a project can use a package it never declared, and how published packages get versioned.
- Workspace linking: internal packages are symlinked, not downloaded (
"@acme/ui": "workspace:*"), and the protocol becomes a real version when published. - Single-version policy (pnpm catalogs, or a lint check) means one copy of each library and one upgrade for everyone, which every project's tests must pass. Independent versions let each team upgrade alone, at the price of drift and duplicate copies.
- Hoisting and phantom dependencies: a flat
node_moduleslets code import packages it never declared, until someone else removes them. An isolated layout (pnpm's default) links only declared dependencies, so the mistake fails right away. - Releases: record each change with a bump type, then version everything at once. A dependent whose range the new version leaves gets a patch bump (Changesets; since 3.0 that includes peer dependencies). A fixed group moves in lockstep; independent versions move alone.
Try it: dependency policy, a phantom dependency, and a release
1. One third-party library (date-fns), six projects
| Project | package.json says | Installed |
|---|---|---|
| apps/web | "date-fns": "^4.1.0" | 4.1.0 |
| apps/admin | "date-fns": "^3.6.0" | 3.6.0behind |
| apps/marketing | "date-fns": "^2.30.0" | 2.30.0behind |
| packages/auth | "date-fns": "^4.1.0" | 4.1.0 |
| packages/billing | "date-fns": "^3.6.0" | 3.6.0behind |
| packages/ui | "date-fns": "^4.1.0" | 4.1.0 |
3 copies of date-fns installed; 3 projects behind 4.1.0 (admin, marketing, billing). A shared component that passes a date between two of them can hit two incompatible copies.
2. A phantom dependency
apps/marketing imports lodash-es but never lists it in its package.json. packages/ui does list it.
- Today
- Buildslodash-es was hoisted to the root node_modules for ui, so marketing finds it by accident.
- After the UI team removes lodash-es from ui
- FailsCannot find module 'lodash-es'. marketing's own code didn't change; another team's cleanup broke it.
3. Record changes, then version the published packages
Pending changesets (1)
- "@acme/utils": majorDrop the CommonJS build.
Release plan
@acme/ui 2.3.0 → 2.3.1 (patch: dependency @acme/utils left its range)
## 2.3.1 ### Patch Changes - Updated dependencies - @acme/utils@2.0.0
@acme/api-client 0.9.1 → 0.9.2 (patch: dependency @acme/utils left its range)
## 0.9.2 ### Patch Changes - Updated dependencies - @acme/utils@2.0.0
@acme/utils 1.4.2 → 2.0.0 (major: changeset)
## 2.0.0 ### Major Changes - Drop the CommonJS build.
Unreleased: none. The private apps and features aren't versioned at all: they pick up the new code through the workspace link and ship with their next deploy.
Do: Default to one version per library, an isolated node_modules, and a changeset with every change to a published package.
Avoid: Importing what you didn't declare, or hand-editing versions and changelogs.
Read more:pnpm: catalogs(opens in a new tab)pnpm: why strict node_modules(opens in a new tab)Changesets: config(opens in a new tab)
Deep dive: CI and developer experience
"Won't CI get slow?" is the first objection to any monorepo. Running everything does grow with the whole repo. Running only affected projects, skipping cached tasks and spreading the rest over agents makes CI grow with the change instead.
- The four strategies stack. Affected runs depend on git history (fetch enough of it in CI); the shared cache needs complete hashes; parallel agents can't beat the longest chain of dependent tasks except by caching tasks on it.
- Run one app without building the world: start its dev server and let the runner build only its dependencies, or have the app consume packages as source (no build step, nothing to cache, more work for the app's bundler).
- Incremental type checking: TypeScript project references and
tsc -brebuild only stale packages. The cost iscompositeconfigs and emitted declaration files. - Generators scaffold a new package with the right config, tags and owners, so new code starts inside the rules. They do need maintaining. Editor speed drops as the repo grows: project references and scoped workspaces keep the language server on what you have open.
Try it: CI time as the repo grows
- Run everythingEvery task of every project on one agent2 h 32 min
- Affected onlyOnly projects the change can reach32 min
- Affected + shared cacheSkip tasks whose hash is already in the remote cache16 min
- + parallel agentsSpread the rest over 6 agents5.8 min
| Strategy | 10 projects | 50 projects | 200 projects | 1000 projects |
|---|---|---|---|---|
| Run everything | 9.0 min | 39 min | 2 h 32 min | 12 h 32 min |
| Affected only | 6.8 min | 9.1 min | 32 min | 2 h 32 min |
| Affected + shared cache | 3.7 min | 5.2 min | 16 min | 1 h 14 min |
| + parallel agents | 3.6 min | 4.7 min | 5.8 min | 14 min |
The model's assumptions
- Each project has build, test and lint, 3 min in total; one agent runs 4 tasks at a time.
- The longest chain of dependent tasks is about log₂(projects) + 2 tasks (9.6 min here). No strategy beats it except by caching the tasks on it.
- The shared cache already has 60% of the affected tasks (from main, an earlier push or a teammate); restoring one costs 1.2 s.
- Parallel agents: 6. Every run pays 1.5 min of checkout and install.
A model, not a benchmark: the numbers move with your repo, but the shape doesn't. Run everything grows with the whole repo, affected runs with the change, and caching and agents cut what remains.
Do: Run affected tasks against the last green main, share the cache with every pull request, and measure CI time per pull request over time.
Avoid: Shallow CI checkouts (every package looks changed), or adding agents before you add caching.
Read more:Turborepo: run --affected(opens in a new tab)Turborepo: internal packages(opens in a new tab)TypeScript: project references(opens in a new tab)Nx: generators(opens in a new tab)
Best practices: structure, layers and owners
A lot of the pain in a large monorepo goes back to choices made in its first weeks: where code goes, how a package is shaped, and who reviews what. These rules settle them early. Try them on the sample repo in the demo.
- Keep code in two top-level folders:
apps/for what you deploy andpackages/for what you share. The root holds only workspace and tooling files (the rootpackage.json, the workspace file, the task pipeline, CODEOWNERS), so the whole layout fits on one screen. - Give each package one purpose, its own
package.json, and anexportsfield listing its public entry points. If you need "and" to describe a package, split it. Whateverexportsleaves out is private, and its owners can change it without breaking anyone. - Put every package under one npm scope and name it after its folder, so
packages/uiis@acme/ui. Mark internal packages"private": trueso nobody publishes them by accident. - Agree on the layers before the second app arrives. Here that is apps, then features, then UI and data, then utilities, with imports only going down. Tag each project with its layer and check the rule in lint and CI, so it holds even when a reviewer misses it.
- Ship shared config (TypeScript, ESLint, test setup) as packages that projects extend. Pin the package manager in the root
package.jsonand keep third-party versions in one catalog, so laptops and CI install the same tools. - Give every project one owning team in CODEOWNERS. Write the catch-all first and specific paths after it, because the last matching line wins. Then turn on required code-owner review in branch protection; without it, owners are only requested as reviewers.
Try it: explore the layout and test CODEOWNERS
1. The sample repo, folder by folder
packages/ui/
@acme/ui
The design system: components and tokens with no business logic and no data fetching, so any app can use it.
- Layer
- UI
- May import
- Utility, Config
- Published
- Yes, 2.3.0
- Code owners
- @acme/design-system
2. Who has to review this path?
.github/CODEOWNERS
- *@acme/platformmatches
- *.md@acme/docs
- /apps/web/@acme/web-team
- /apps/admin/@acme/internal-tools
- /apps/marketing/@acme/growth
- /packages/auth/@acme/identity
- /packages/billing/@acme/payments
- /packages/ui/@acme/design-systemowner (last match)
- /packages/ui/src/tokens/@acme/design-system @acme/brand
- /packages/api-client/@acme/platform
- /packages/utils/@acme/platform
Lines 1, 8 match packages/ui/src/button.tsx. Line 8 comes last, so @acme/design-system owns it.
Simplified matcher: it handles *, **, anchored paths and folder patterns. GitHub reads the file from the pull request's base branch.
Do: Start with apps/ and packages/, one purpose and an exports field per package, one scope, layers checked in lint, and a catch-all on the first CODEOWNERS line.
Avoid: Code at the repo root, a
commonorsharedpackage that everything imports, or a catch-all at the bottom of CODEOWNERS.
Read more:GitHub: about code owners(opens in a new tab)Turborepo: structuring a repository(opens in a new tab)Node.js: package entry points(opens in a new tab)npm: the private field(opens in a new tab)
Back to the method: this case study used the same stages as the design method, the news feed and the autocomplete, but the users are engineers and the bars are CI time, ownership and release safety. Next, Design micro frontends looks at the other half of the problem: letting each team release on its own.