Frontend Handbook
Written

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.

01

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

Sample monorepoThree apps (web, admin, marketing) depend on two feature packages (auth, billing; billing also uses auth), which depend on a UI kit and an API client, which both depend on a utilities package. Every project uses the shared tsconfig and ESLint config. A package manager links the packages; a task runner builds them in this order and caches the results.AppsFeaturesUI · dataUtilitiesEvery project uses it;arrows from ui and api onlyTooling every project uses (arrows omitted)webapps/webadminapps/adminmarketingapps/marketing@acme/authpackages/auth@acme/billingpackages/billing@acme/uipackages/ui@acme/api-clientpackages/api-client@acme/utilspackages/utils@acme/tsconfigpackages/tsconfig@acme/eslint-configpackages/eslint-config
One example. Arrows point from a project to what it depends on, so builds run bottom-up. Under it sit the workspace (install and link) and the task runner (graph, cache, affected). The same graph drives every demo on this page. A smaller team could start with apps and one shared package and split later.
  • 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)

02

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, strict node_modules and 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

What does your repo need?

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).

What each tool does, as of October 2026. Picked needs are highlighted.
Capabilitypnpm workspacesPackage manager · 12.14npm workspacesPackage manager · 12.2TurborepoTask runner · 2.11NxTask runner + plugins · 23.3ChangesetsRelease tool · 3.0
Workspaces and linkingYesworkspace: protocol, rewritten to a real version on publish; catalogs for shared versionsPartlySymlinks workspaces into node_modules; no workspace: protocolNoUses the package manager's workspacesPartlyUses package-manager workspaces or TypeScript path aliasesNoNot its job
Task graphPartlypnpm -r runs scripts in topological order, with no task-level dependenciesNo--workspaces runs in package.json order, not dependency orderYesdependsOn: ["^build"] runs dependencies firstYesdependsOn per target or in targetDefaultsNoNot its job
Local task cacheNoNot its jobNoNot its jobYesHashes inputs; a hit restores outputs and replays logsYesHashes inputs; a hit restores outputs and replays logsNoNot its job
Shared (remote) cacheNoNot its jobNoNot its jobYesVercel Remote Cache, or self-host the open Remote Caching APIYesNx Cloud, or a self-hosted server built to its OpenAPI specNoNot its job
Affected runsPartly--filter "...[origin/main]" selects changed packages and their dependentsNoNot its jobYes--affected (changed packages and their dependents vs main)Yesnx affected -t <task> with --base/--headNoNot its job
Boundary rulesNoStrict node_modules blocks undeclared dependencies, not layer rulesNoNot its jobPartlyturbo boundaries is experimental: undeclared imports, tag allow/deny rulesYesenforce-module-boundaries lint rule with project tagsNoNot its job
Code generatorsNoNot its jobPartlynpm init -w scaffolds an empty workspaceYesturbo gen workspace, plus custom generatorsYesnx g, from plugins or local generatorsNoNot its job
Versioning and releaseNoPublishes (pnpm -r publish) but doesn't bump versionsNoNot its jobNoIts docs point to ChangesetsYesnx release: version, changelog and publishYesChange 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)

03

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

What your branch changed (compared with main)
Affected projectsChanged: @acme/ui. Affected: web, admin, marketing, @acme/auth, @acme/billing, @acme/ui.AppsFeaturesUI · dataUtilitiesEvery project uses it;arrows from ui and api onlyTooling every project uses (arrows omitted)webapps/webadminapps/adminmarketingapps/marketing@acme/authpackages/auth@acme/billingpackages/billing@acme/uipackages/ui@acme/api-clientpackages/api-client@acme/utilspackages/utils@acme/tsconfigpackages/tsconfig@acme/eslint-configpackages/eslint-config

changed affected (depends on a change) unaffected

6 of 8 projects with tasks are affected.

Task
Run it for
Machine
Cache

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)

04

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 exports field is its API. A deep import into src/ 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

Through

apps/web/src/index.ts: import { … } from "@acme/billing"

Allowed

Review needed from @acme/web-team (CODEOWNERS for apps/web/).

Import being checkedweb importing @acme/billing: allowed.AppsFeaturesUI · dataUtilitiesEvery project uses it;arrows from ui and api onlyTooling every project uses (arrows omitted)webapps/webadminapps/adminmarketingapps/marketing@acme/authpackages/auth@acme/billingpackages/billing@acme/uipackages/ui@acme/api-clientpackages/api-client@acme/utilspackages/utils@acme/tsconfigpackages/tsconfig@acme/eslint-configpackages/eslint-config

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)

05

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_modules lets 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

Version policy
Projectpackage.json saysInstalled
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.

node_modules layout
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

Versioning
How packages depend on each other
Package
Bump
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)

06

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 -b rebuild only stale packages. The cost is composite configs 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

Repo size
Share of projects a typical change reaches
Estimated CI time per pull request: 200 projects, a change reaching 20% of them
  • 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
How each strategy grows with the repo, for a change reaching 20% of projects
Strategy10 projects50 projects200 projects1000 projects
Run everything9.0 min39 min2 h 32 min12 h 32 min
Affected only6.8 min9.1 min32 min2 h 32 min
Affected + shared cache3.7 min5.2 min16 min1 h 14 min
+ parallel agents3.6 min4.7 min5.8 min14 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)

07

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 and packages/ for what you share. The root holds only workspace and tooling files (the root package.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 an exports field listing its public entry points. If you need "and" to describe a package, split it. Whatever exports leaves 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/ui is @acme/ui. Mark internal packages "private": true so 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.json and 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

Repo entries

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?

CODEOWNERS file

.github/CODEOWNERS

  1. *@acme/platformmatches
  2. *.md@acme/docs
  3. /apps/web/@acme/web-team
  4. /apps/admin/@acme/internal-tools
  5. /apps/marketing/@acme/growth
  6. /packages/auth/@acme/identity
  7. /packages/billing/@acme/payments
  8. /packages/ui/@acme/design-systemowner (last match)
  9. /packages/ui/src/tokens/@acme/design-system @acme/brand
  10. /packages/api-client/@acme/platform
  11. /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 common or shared package 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.