1. What Are Micro-Frontends, and When They're Worth It
A micro-frontend is the same idea as a microservice, applied to the browser: instead of one team owning one large frontend codebase, multiple independently deployable frontend pieces — each owned, built, tested, and shipped by a different team — combine at runtime into what the end user experiences as a single product. One team might own the checkout flow, another the product catalog, another account settings — each deploys on its own schedule, without coordinating a release with the others.
That's genuinely valuable in the right situation, and genuinely harmful complexity in the wrong one. Being direct about which signals point which way matters more than knowing the mechanics — the mechanics (Sections 2–5) are the easy part.
- Signals it's justified — multiple teams, each large enough to own a full vertical slice, shipping on independent schedules that a shared release train would bottleneck; genuinely separate product surfaces (a marketing site vs. a logged-in dashboard vs. an admin panel) with different performance and update-cadence needs; an org where the coordination cost of one shared codebase across teams has become the actual bottleneck, measurably.
- Signals it's premature — a single team, or a single app, reaching for it because the pattern sounds sophisticated or "how big companies do it"; a codebase whose complexity problem is really about internal organization (feature folders, module boundaries) rather than needing separate deployments at all; a team of five people who would spend more time on Module Federation configuration and cross-app coordination than they'd ever save.
If you're not sure whether you need this, you almost certainly don't yet. A well-organized single app (clear feature folders, enforced module boundaries, a fast CI pipeline) solves the same "codebase feels unwieldy" pain for most teams without any of this week's runtime complexity. Reach for micro-frontends when the actual constraint is organizational — multiple independent teams needing independent deploys — not when it's just code organization.
2. Module Federation Basics
Module Federation is the mechanism that makes micro-frontends practical at the code level: it lets one already-built, already-deployed JavaScript application (a remote) expose specific modules — a component, a hook, a whole page — that a different application (the host, or "shell") can import and render at runtime, without either app needing to be rebuilt or redeployed when the other changes. This is meaningfully different from an iframe: components rendered via Module Federation share the same DOM, the same React tree context can be threaded through deliberately, and there's no iframe sandboxing overhead or cross-frame messaging required just to pass props.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutSummary': './src/components/CheckoutSummary.tsx',
},
shared: ['react', 'react-dom'], // avoid loading React twice across host + remote
}),
],
build: { target: 'esnext', modulePreload: false, cssCodeSplit: false },
});
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell',
remotes: {
// points at the deployed remote's manifest -- a URL, not a local import
checkout: 'https://checkout.example.com/assets/remoteEntry.js',
},
shared: ['react', 'react-dom'],
}),
],
build: { target: 'esnext' },
});
import { lazy, Suspense } from 'react';
// looks like a normal dynamic import, but resolves against the remote's
// deployed remoteEntry.js at runtime -- checkout's own deploys update this
// automatically, with no rebuild of the shell required
const CheckoutSummary = lazy(() => import('checkout/CheckoutSummary'));
function CartPage() {
return (
<div>
<h1>Your Cart</h1>
<Suspense fallback={<p>Loading checkout...</p>}>
<CheckoutSummary />
</Suspense>
</div>
);
}
The shared: ['react', 'react-dom'] line matters more than it looks —
without it, the host and remote would each ship their own copy of React, and mounting
a remote component inside the host's tree would violate React's rule that only one
copy of React can own a given component tree, causing subtle hook and context bugs.
Module Federation deduplicates shared dependencies at load time when versions are
compatible.
3. Monorepo Structuring with Nx or Turborepo
It's easy to conflate "micro-frontends" with "monorepo" because they're often adopted together, but they answer different questions. A monorepo is about where your code lives — one repository, multiple packages, shared tooling and dependency versions. Micro-frontends are about how your app deploys — multiple independently-releasable runtime units. You can have either without the other: a monorepo of packages that all build into one deployed app, or multiple repos (each its own git history) that federate together at runtime.
my-org/
├── apps/
│ ├── shell/ # the host app -- owns routing, top-level layout
│ ├── checkout/ # remote -- owned by the checkout team
│ └── catalog/ # remote -- owned by the catalog team
├── packages/
│ ├── ui/ # shared design-system components (Section 4)
│ ├── api-client/ # shared typed fetch wrappers
│ └── config/ # shared eslint/tsconfig presets
├── turbo.json
└── package.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // build a package's dependencies first
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
The payoff of a monorepo here isn't the runtime architecture at all — it's developer
experience: turbo run build figures out the dependency graph between
packages/ui, packages/api-client, and every app that consumes
them, builds only what changed (and its dependents), and caches the rest. A checkout
team changing apps/checkout doesn't rebuild apps/catalog
unless catalog actually depends on something checkout touched.
Both solve the same core problem (task graphs, caching, affected-only builds). Nx leans toward a more opinionated, plugin-driven setup with generators and a built-in project graph visualizer — a good fit for larger orgs standardizing many similar apps. Turborepo is deliberately minimal — closer to "just a fast task runner" on top of your existing tools — a good fit when you want the caching wins without adopting a broader framework's conventions.
5. Cross-App Communication & Independent Deployability
Two independently-deployed apps occasionally need to talk to each other — the shell needs to know when checkout finishes, or catalog needs to tell the shell to update a cart badge count. The instinct to reach for a shared global store (a cross-app Redux instance, say) is exactly the wrong move: it re-creates the tight coupling — one shared piece of state every app must agree on the shape of, version together, and never break — that independent deployability was supposed to eliminate.
Three lighter-weight patterns keep apps decoupled while still letting them communicate:
// inside the checkout remote, after a successful purchase
window.dispatchEvent(
new CustomEvent('cart:updated', { detail: { itemCount: 0 } })
);
// inside the shell, listening -- doesn't need to know anything about checkout's internals
useEffect(() => {
function handleCartUpdate(e: Event) {
const { itemCount } = (e as CustomEvent).detail;
setCartBadgeCount(itemCount);
}
window.addEventListener('cart:updated', handleCartUpdate);
return () => window.removeEventListener('cart:updated', handleCartUpdate);
}, []);
// packages/event-bus/src/index.ts
type Events = { 'cart:updated': { itemCount: number } };
class EventBus {
private target = new EventTarget();
emit<K extends keyof Events>(name: K, detail: Events[K]) {
this.target.dispatchEvent(new CustomEvent(name, { detail }));
}
on<K extends keyof Events>(name: K, handler: (detail: Events[K]) => void) {
const listener = (e: Event) => handler((e as CustomEvent).detail);
this.target.addEventListener(name, listener);
return () => this.target.removeEventListener(name, listener);
}
}
export const eventBus = new EventBus(); // typed, but still just a thin events wrapper
Option 3 — URL/query-param state — is often the simplest of all for
anything that should survive navigation between apps: a ?promoCode=SAVE10
carried across a redirect from catalog to checkout requires no
shared code whatsoever, just an agreed-on param name, and works even if the two apps
are on entirely separate domains.
What "independently deployable" actually buys a team day to day: the checkout team can ship a fix on a Tuesday afternoon without asking the catalog team to also deploy, without a shared release branch, and without their change being blocked by an unrelated team's in-progress work. That's the entire point — every pattern in this section is chosen specifically because it doesn't require the two teams' deploys to be coordinated.
Ask: "if the other app changes its internal implementation tomorrow, does my app still work?" A shared store fails this test — internal shape changes ripple outward. Events and URL params pass it, as long as the event names and param names themselves are treated as a small, stable, versioned contract — the same discipline you'd apply to a public API.
6. Hands-on Exercise
Sketch a two-app Module Federation setup plus a shared Button package
Real config files for a host/remote pair and a shared UI package — running it end-to-end is optional, but every file should be complete and correct. Then prove the "independently deployable" claim actually holds.
Part 1 — The setup:
- Write two
vite.config.tsfiles: aremoteapp (apps/widgets) exposing one component, and ashellhost app configured to consume it — model both on Section 2's examples, with realexposes/remotesentries andshared: ['react', 'react-dom']. - In the shell, write the consuming page: a
lazy(() => import('widgets/SomeComponent'))call wrapped inSuspensewith a real fallback. - Create a
packages/uifolder structure with a workingButton.tsx(props, variants,refaccepted as a plain prop per Section 4) and a minimalpackage.json, then import and use it from both the shell and the remote app to prove it's genuinely shared. - Add a
turbo.jsonwith at leastbuild,lint, anddevtasks, including correctdependsOnsopackages/uibuilds before any app that consumes it. - Pick one cross-app communication need (e.g. "shell needs to know when widgets adds an item to a list") and implement it with a custom event, not a shared store — include both the
dispatchEventand the listeninguseEffect.
If you don't have two real deployed apps to point remotes at, run both locally on different ports (e.g. localhost:5001 for the remote, localhost:5000 for the shell) and point the host's remotes entry at http://localhost:5001/assets/remoteEntry.js — the mechanism is identical to production, just pointed at a local dev server instead of a deployed URL.
Part 2 — Prove independent deployability:
Section 1's whole argument for micro-frontends is that teams can ship on their own schedule. Test whether your setup actually delivers that, rather than assuming it does.
- Bump
packages/ui'sversionin itspackage.json(e.g.3.2.0→4.0.0) and make a visibly breaking change toButton— rename thevariantprop totone, for instance. - Update only
apps/widgetsto the new version and the new prop name; leaveapps/checkout(the shell) pinned to the oldpackages/uiversion, unchanged. - Run
turbo run buildand confirm both apps still build successfully —apps/checkouton the oldButtonAPI,apps/widgetson the new one, with no forced simultaneous migration. - Add a small runtime guard in the shell: compare
React.versionread in the shell against the version reported by the remote module once it loads (log both to the console), and write a comment explaining what a mismatch here would actually mean at runtime, givenshared: ['react', 'react-dom']from Section 2.
If both apps' builds still succeed independently even with mismatched packages/ui versions, that's the actual proof this exercise is after — a monorepo with proper package boundaries doesn't force a lockstep upgrade just because a team is ready to move to a new version, only when a specific consumer chooses to.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What organizational signal actually justifies adopting micro-frontends, as opposed to reaching for them because they sound impressive?
What organizational signal actually justifies adopting micro-frontends, as opposed to reaching for them because they sound impressive?
Multiple teams, each large enough to own a full vertical slice of the product, needing to ship on genuinely independent schedules that a single shared codebase and release process would bottleneck. A single team or single app wanting the pattern for its own sake — without that organizational pressure already existing — is the signal it's premature complexity rather than a solution to a real constraint.
Q2
What does Module Federation solve that simply embedding another app in an <iframe> doesn't?
What does Module Federation solve that simply embedding another app in an <iframe> doesn't?
A federated component renders inside the host's own DOM and React tree — sharing layout, styling context, and (with a shared dependency) the same React instance — so it behaves like a normal component the host can pass props to directly. An iframe is a fully isolated document: no shared DOM, no direct prop passing, and cross-frame communication requires postMessage plumbing just to exchange basic data.
Q3
Why are "use a monorepo" and "adopt micro-frontends" independent decisions rather than the same decision?
Why are "use a monorepo" and "adopt micro-frontends" independent decisions rather than the same decision?
A monorepo answers where code lives — one repository with shared tooling and dependency versions across packages. Micro-frontends answer how an app deploys — multiple independently-releasable runtime units. A team can have a monorepo where everything still builds into one deployed app, or run multiple federated apps that each live in their own separate repository; neither choice implies the other.
Q4
Why does a shared global store between independently-deployed micro-frontends defeat the point of the architecture?
Why does a shared global store between independently-deployed micro-frontends defeat the point of the architecture?
The entire motivation for micro-frontends is letting teams deploy independently without coordinating with each other. A shared store means every app depends on one common piece of state whose shape all of them must agree on, version together, and never break in isolation — reintroducing exactly the cross-team coordination cost that splitting into separate deployable apps was meant to remove.