Topic 22.6
Large Apps: Monorepos, Micro-Frontends and Module Boundaries
In one line
When many teams share one frontend, a monorepo keeps several apps and packages in one repository with shared tooling, and micro-frontends split one product into separately deployed apps composed at runtime (for example with module federation); both enforce module boundaries, both have real costs, and most apps are better served by a well-structured single app first.
Think of it like this
A shopping centre. One option is a single big shop (one app): one owner, one till, easy to keep consistent. Another is one building with many shops that share the car park, the cleaning and the security (a monorepo): separate shops, shared services, one management. The third is independent shops that each open, close and renovate on their own schedule, joined by corridors with agreed rules (micro-frontends). Each layout suits a different number of owners, and each costs something.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Module boundary
- A line in the code that others may cross only through a declared interface.
- Monorepo
- One Git repository holding several apps and packages that are developed together.
- Workspace
- A package-manager feature that links local packages in a monorepo so they can depend on each other.
- Turborepo / Nx
- Build orchestrators that run tasks across a monorepo in dependency order, only for what changed, with caching.
- Micro-frontend
- A separately built and deployed part of a frontend, owned by one team and composed into the page by a host.
- Host (shell)
- The app that loads micro-frontends and decides where each one appears.
- Module federation
- A bundler feature that lets one app load modules from another app's deployment at runtime and share dependencies.
- Singleton dependency
- A library (like React) that must exist only once on the page, so all apps share the same copy.
Step by step
01Grow in steps: boundaries first
Start with one app and feature folders enforced by lint (Topic 22.1). When a second app needs the same UI (an admin panel), move shared code into packages in a monorepo. Only when several teams are blocked on one release does splitting deployment pay off.
02A monorepo layout
Apps are deployable; packages are shared libraries. The exports field of each package is its public API, which is a stronger boundary than a convention: deep imports into a package simply don't resolve.
acme/
apps/
web/ package.json ("@acme/ui": "workspace:*")
admin/ package.json ("@acme/ui": "workspace:*")
packages/
ui/ package.json "exports": { ".": "./src/index.js" }
config/ eslint, tsconfig and vite presets
pnpm-workspace.yaml packages: ["apps/*", "packages/*"]
turbo.json
package.json03Let the orchestrator build only what changed
"dependsOn": ["^build"] means "build my dependencies first". With caching, a change to apps/admin doesn't rebuild or retest apps/web, and a teammate's CI run can reuse your cached results.
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["^build"] },
"lint": {}
}
}04Micro-frontends: a host composes remotes at runtime
Each remote is deployed at its own URL. The host loads its entry file when needed and shares one copy of React with it.
05Configure module federation (Vite plugin)
The remote exposes modules; the host lists remotes by URL; both mark React as a shared singleton. The webpack and Rspack plugins use the same option names.
// reviews (remote)
federation({
name: "reviews",
filename: "remoteEntry.js",
exposes: { "./Reviews": "./src/Reviews.jsx" },
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
// shop (host)
federation({
name: "shop",
remotes: {
reviews: { type: "module", name: "reviews", entry: "https://reviews.example.com/remoteEntry.js" },
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
// in the host's code
const Reviews = lazy(() => import("reviews/Reviews"));Try it yourself
- 1
Add a second event
In the first example, give the cart a Clear button that emits
cart:clear, and make the catalog showCart was clearedwhen it hears it. Which code had to know about the other team's internals? None: you extended the contract. - 2
Break the version promise
In the remote example, change
requires: "19"torequires: "20". Predict what the slot shows and whether the rest of the page still works. - 3
Simulate a remote outage
Change
<RemoteSlot url={remoteUrl} ...>tourl="data:text/javascript,throw new Error('down')". Predict the screen. This is why every remote needs a fallback, ideally an error boundary too.
Code & diagrams
The two apps share no state, no context and no imports: only the `cart:add` event and its `{ name }` payload, which is the whole contract. Unmounting one app leaves the other running. In production each `mount` function would come from a different deployment.
Preview
Console
Nothing logged yet.Expected console output
event cart:add { name: 'Tea' }
event cart:add { name: 'Coffee' }
Cart: 2 (Tea, Coffee)
catalog unmounted, cart still shows: Cart: 2 (Tea, Coffee)The host downloads code it has never seen at build time, checks a version promise, and passes its own React in so only one copy exists. If the remote's URL is down, the host shows a fallback instead of crashing: every remote is a network dependency that can fail. Click the stars: the remote's own state works.
Preview
Console
Nothing logged yet.Expected console output
loaded remote reviews needs React 19 - host has 19.2.8
slot shows: Reviews for product 7: ★★★★$ cat apps/web/package.json
{
"name": "@acme/web",
"dependencies": {
"@acme/ui": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
}
$ pnpm --filter @acme/web dev # run one app
$ pnpm turbo run build --filter=...[origin/main]
# builds only the packages changed since main, plus everything that depends on themBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Two copies of React on one page
Let a remote bundle its own react instead of sharing the host's (forget singleton: true), and render a remote component that calls useState inside the host's tree.
Break #2
A shared global store between micro-frontends
Put a Redux store on window.store and let every micro-frontend read and write any slice of it.
Myth vs fact
Myth
Micro-frontends make apps faster.
Fact
They usually make the first load slower (more requests, duplicated code). They make teams faster by removing release coordination.
Myth
A monorepo means one giant app.
Fact
A monorepo can hold many independently deployed apps. It's about where code is stored and how it's built, not how it's deployed.
Myth
Module federation is required for micro-frontends.
Fact
It's one runtime approach. Others include iframes, server or edge composition, web components, and a simple mount()/unmount() contract (single-spa).
Myth
Large companies always need micro-frontends.
Fact
Many large products (including big React codebases) run as a single app or a monorepo with strong boundaries. Choose by team structure and release pain, not company size.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Unmounting a nested React root inside the host's effect cleanup can warn that you "attempted to synchronously unmount a root while React was already rendering"; defer the remote's
unmount()withqueueMicrotaskorsetTimeoutwhen the host tears down during a render. - ▸
Module federation 2.0 (
@module-federation/enhancedand the Vite/Rspack plugins) adds a runtime API (loadRemote), type hints generated from remotes, and a manifest (mf-manifest.json) so hosts can discover remote versions without hard-coding URLs. - ▸
Server-rendered frameworks complicate runtime composition: each remote must render on the server too, or be client-only islands. Next.js's App Router doesn't support module federation officially; its answer for multiple teams is Multi-Zones (separate Next.js apps behind one domain, joined by path rewrites).
- ▸
Conway's law (systems mirror the communication structure of the organisations that build them) is the real driver: draw module boundaries where team boundaries are, so most changes stay inside one team's code.
Remember this
- 1
A module boundary is a line in the code that other code may only cross through a declared interface: a feature's
index.js(Topic 22.1), a package'sexportsfield, or a micro-frontend'smount()function. Boundaries let teams change their insides without asking anyone. The bigger the organisation, the stronger the boundary needs to be: a lint rule, then a package, then a separate deployment. - 2
A monorepo is one Git repository holding several projects: for example
apps/web,apps/admin,packages/ui,packages/config. A workspace tool (npm, pnpm or Yarn workspaces) links local packages together, soapps/webcan depend on"@acme/ui": "workspace:*"and see changes instantly. A build orchestrator (Turborepo or Nx) knows the dependency graph, runs tasks in the right order, runs only what changed, and caches results locally or remotely. Benefits: one place for shared UI and configs, atomic changes across apps (change theButtonand every app in one pull request), one set of tool versions. Costs: tooling to learn, CI that must be smart about what to build, and repository-wide upgrades. - 3
A micro-frontend architecture splits one product into several frontend apps owned by different teams and deployed independently: the catalog team ships at 10:00, the checkout team at 15:00, without coordinating a release. A host (or shell) app composes them into one page. Composition can happen at build time (each micro-frontend is an npm package, which loses independent deployment), on the server (edge or server-side includes), or at runtime in the browser, which is the most common.
- 4
Module federation is the best-known runtime approach. It started as a webpack 5 plugin and now also exists for Rspack and Vite (
@module-federation/vite). A remote app builds a smallremoteEntry.jsthat exposes modules (./Reviews). The host loads that file from the remote's URL at runtime and imports the exposed module as if it were local:const Reviews = lazy(() => import("reviews/Reviews")). Shared dependencies are negotiated so the page loads one copy of React: you markreactandreact-domassingleton. Two copies of React on one page break hooks and context. - 5
A simpler runtime contract, used by single-spa and many in-house setups, is that every micro-frontend exports
mount(element, props)that returnsunmount(), each mounting its own React root. Communication goes through narrow channels: URL changes, browserCustomEvents with documented names, or props from the shell. Shared global stores across micro-frontends recreate the coupling you were trying to remove. - 6
Be honest about the costs. Micro-frontends add network requests and duplicate code (bigger downloads, slower first load), make shared design and accessibility harder to keep consistent, need version agreements for shared libraries, complicate local development and end-to-end testing, and spread one user journey across several teams' failure modes. They solve an organisational problem (many teams blocked on one release train), not a technical one. If you have one or two teams, a single app with feature folders and lint boundaries, or a monorepo, gives most of the benefits at a fraction of the cost.
Explain it without notes
What is a monorepo and what problems does it solve for a React codebase?
What are micro-frontends, and when would you recommend them?
Explain module federation and why React must be shared as a singleton.
What are the costs of micro-frontends?
Practice
Write a mount(el, props) contract for a Newsletter micro-frontend that also supports updating props later (update(props)) and unmounting.
Write a pnpm-workspace.yaml and the package.json dependency that lets apps/web use a local @acme/ui package.
Trade-offs
- ↔
Single app: simplest and fastest for users; boundaries depend on discipline and lint rules. Monorepo: shared code and atomic changes; needs build tooling. Micro-frontends: independent deploys; pays in performance, consistency and operational complexity.
- ↔
Runtime composition gives the most independence and the most failure modes; build-time composition (packages) is safer but brings back coordinated releases.
Done when you can
I can explain module boundaries at the folder, package and deployment level.
I can describe a monorepo layout with workspaces and Turborepo or Nx.
I can explain what micro-frontends are and when they are worth their costs.
I can explain module federation, remotes, exposes and shared singletons.
I can design a
mount/unmountcontract and an event-based channel between micro-frontends.