Week 26: Micro-Frontends & Advanced Architecture

The capstone in Weeks 25–26 is a single app, built by one person, deployed as one unit. This week looks at how the same ideas scale when many teams ship many apps that still need to feel like one product to the end user — and, just as importantly, at when that complexity genuinely isn't worth it yet. Most of what follows is a set of tools for a problem you may not have; the harder skill is recognizing which situation you're actually in.

Module 16 of 17 Week 26 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what a micro-frontend is and identify the organizational signals that justify it (and the ones that don't)
  • Set up a basic Module Federation host/remote relationship between two apps
  • Structure a monorepo with a shared design-system package consumed by multiple apps

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.
The honest default

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.

apps/checkout (the "remote") — vite.config.ts
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 },
});
apps/shell (the "host") — vite.config.ts
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' },
});
apps/shell/src/pages/CartPage.tsx — consuming the remote component
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.

repo layout — apps/ vs. packages/
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
turbo.json — defines the task graph and what can run in parallel/cached
{
  "$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.

Nx vs. Turborepo, briefly

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.

4. Shared Design Systems & Component Libraries

When multiple teams ship independently-deployed apps, keeping the product feeling like one product requires a shared source of truth for the basics — buttons, form fields, spacing, color tokens. A packages/ui package inside the monorepo from Section 3 is the standard answer: one place to define a component once, and every app imports it rather than re-implementing its own button.

packages/ui/src/Button.tsx
import { type ButtonHTMLAttributes, type Ref } from 'react';
import styles from './Button.module.css';

type Variant = 'primary' | 'secondary' | 'danger';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  ref?: Ref<HTMLButtonElement>;
}

export function Button({ variant = 'primary', className, ref, ...rest }: ButtonProps) {
  return (
    <button ref={ref} className={`${styles.button} ${styles[variant]} ${className ?? ''}`} {...rest} />
  );
}
packages/ui/package.json
{
  "name": "@my-org/ui",
  "version": "3.2.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "peerDependencies": { "react": "^19.0.0" }
}
apps/checkout/src/CheckoutSummary.tsx — consuming it like any npm package
import { Button } from '@my-org/ui';

function CheckoutSummary() {
  return (
    <div>
      <p>Total: $84.00</p>
      <Button variant="primary">Place order</Button>
    </div>
  );
}

This creates a real tension worth naming honestly. Sharing @my-org/ui is exactly what keeps checkout and catalog looking like the same product — consistent buttons, consistent spacing, one place to fix an accessibility bug for every app at once. But it's also a coupling point: every app that depends on it now has to deal with its releases, and a breaking change in Button's API means coordinating an upgrade across every consumer — the exact kind of cross-team coordination cost micro-frontends were adopted to avoid in the first place. Semantic versioning and a deliberately small, stable surface area for packages/ui (fewer props, fewer variants, changed rarely) are how teams keep that coupling manageable instead of load-bearing.

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:

option 1 — native custom events on window (no shared library needed at all)
// 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);
}, []);
option 2 — a tiny shared event-bus package (still just events, not shared state)
// 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.

The test for any cross-app communication mechanism

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

Hands-on

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:

  1. Write two vite.config.ts files: a remote app (apps/widgets) exposing one component, and a shell host app configured to consume it — model both on Section 2's examples, with real exposes/remotes entries and shared: ['react', 'react-dom'].
  2. In the shell, write the consuming page: a lazy(() => import('widgets/SomeComponent')) call wrapped in Suspense with a real fallback.
  3. Create a packages/ui folder structure with a working Button.tsx (props, variants, ref accepted as a plain prop per Section 4) and a minimal package.json, then import and use it from both the shell and the remote app to prove it's genuinely shared.
  4. Add a turbo.json with at least build, lint, and dev tasks, including correct dependsOn so packages/ui builds before any app that consumes it.
  5. 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 dispatchEvent and the listening useEffect.
Hint

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.

  1. Bump packages/ui's version in its package.json (e.g. 3.2.04.0.0) and make a visibly breaking change to Button — rename the variant prop to tone, for instance.
  2. Update only apps/widgets to the new version and the new prop name; leave apps/checkout (the shell) pinned to the old packages/ui version, unchanged.
  3. Run turbo run build and confirm both apps still build successfully — apps/checkout on the old Button API, apps/widgets on the new one, with no forced simultaneous migration.
  4. Add a small runtime guard in the shell: compare React.version read 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, given shared: ['react', 'react-dom'] from Section 2.
Hint

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?

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?

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?

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?

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.