Frontend No. 056

Micro-frontends skill for Claude Code

Split the frontend only when the org chart demands it. Makes Claude argue the split before building it. It runs a team-topology test, falls back to a boundary-enforced modular monolith when the test fails, and otherwise defaults to route-level apps behind a proxy, escalating to Module Federation only for a real same-page need. It then pins shared singletons, the route table, typed events, per-app budgets and manifest-based rollback.

  • namemicro-frontends
  • size225 lines · ≈3,150 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Decides whether a frontend should be split into micro-frontends using a team-topology test, and when it should stay a modular monolith frontend instead. Then designs the split — route-level apps behind a proxy or edge, Module Federation 2.0 remotes, server or edge HTML composition, iframes — plus shared dependencies, the design system, routing ownership, cross-app events, a per-app performance budget, and independent deploys with versioned remotes. Use when someone proposes micro-frontends, Module Federation, single-spa or Next.js Multi-Zones, when several teams collide in one frontend repo, or when migrating a legacy SPA piece by piece.

Try asking

  • Three teams keep blocking each other's releases in our React app. Should we move to micro-frontends?
  • Set up Module Federation so the billing team can ship their page without a shell release
  • Our micro-frontends load React twice and the dashboard got slow. Audit the setup

Use it when

  • Someone proposes micro-frontends, Module Federation, single-spa or Next.js Multi-Zones
  • Several teams collide in one frontend codebase and release train
  • A legacy SPA must be replaced route by route
  • An existing micro-frontend setup has duplicate frameworks, style leaks or broken deploys

Skip it when

  • You need module boundaries inside one backend deployable — use modular-monolith instead
  • You only need to find your way around a large monorepo — use monorepo-navigation instead
  • The problem is bundle weight in a single app — use bundle-analysis instead

How to check the result

Block one remote in DevTools and confirm the shell renders a fallback; confirm a single framework copy on composed pages, working deep links per app, and a solo remote deploy that the host picks up without its own release.

The common mistake it prevents

Choosing runtime composition for a team that releases fine from one repo, then paying for version skew, duplicate React and harder debugging with no organizational gain.

~/.claude/skills/micro-frontends/SKILL.md

--- name: micro-frontends description: Decides whether a frontend should be split into micro-frontends using a team-topology test, and when it should stay a modular monolith… license: MIT ---

Micro-frontends solve an organizational problem (teams blocking each other’s releases), not a technical one. Done well means: the split passed the team-topology test in writing, there is one composition approach with a stated reason, users see one product (one design system, one copy of the framework, no extra navigation latency inside a team’s area), and every app ships to production without coordinating a release with another team.

Ground rules

  • Default to a modular monolith frontend. Split only when the topology test below passes.
  • Split vertically (by route or business area, owned end to end by one team) before horizontally (several teams’ fragments on one page). Horizontal splits cost the most.
  • One framework major version across apps that share a page. “Each team picks its own framework” is a migration state, not a target architecture.
  • Shared code is a versioned package with an owner, never a copied folder.
  • Nothing crosses an app boundary except URLs, typed events, and published packages. No shared mutable global store.
  • Every remote can fail. The host must render a fallback, never a blank page.

Workflow

- [ ] 1. Run the team-topology test; write down the result
- [ ] 2. If it fails: modular monolith frontend (stop here, see "When not to split")
- [ ] 3. Draw the split: which team owns which routes and which page fragments
- [ ] 4. Pick the composition approach (default: route-level apps behind a proxy)
- [ ] 5. Define the shared-dependency contract and the design-system package
- [ ] 6. Define routing ownership and the cross-app event contract
- [ ] 7. Set per-app performance budgets and wire them into CI
- [ ] 8. Set up independent deploys: immutable versioned assets, manifest pointer, rollback
- [ ] 9. Verify: fallback on remote failure, single framework copy, budgets, deep links

1. Team-topology test

Answer each question with evidence (team charts, release history, CI times), not opinion:

QuestionPoints toward splitting when
How many teams ship to this frontend?Several stream-aligned teams, each owning a distinct business area
Do releases block each other?Teams wait on a shared release train, freeze, or another team’s failing tests
Can areas map to routes?Most features live under a team-owned URL prefix (/billing/*, /catalog/*)
Different lifecycles?A legacy app must be replaced incrementally (strangler migration)
Is there platform capacity?Someone will own the shell, shared packages, CI templates, and budgets

Split only when the first three point toward splitting and someone owns the platform work. One team, or several teams that already release independently from a monorepo, gains nothing but overhead. A strangler migration is the one case where a single team can justify a temporary split: retire the old app, then fold back.

When not to split: modular monolith frontend

If the test fails, deliver the same isolation inside one deployable:

  • One app, feature folders per business area, each with a public index.ts.
  • Enforce boundaries in CI (ESLint import rules, Nx module-boundary tags, or dependency-cruiser) so areas cannot import each other’s internals.
  • Route-level code splitting gives each area its own chunks; CODEOWNERS gives each team review ownership of its folder.
  • Fix the actual pain first: slow CI (affected-only builds, caching), merge conflicts (smaller modules), release trains (trunk-based development with feature flags).

3–4. Composition approaches

ApproachHowUse whenCost
Route-level apps behind a proxy or edge (default)Each team deploys a full app for its URL prefixes; a reverse proxy, CDN, or edge rewrite routes pathsVertical split; navigation between areas is infrequentHard navigation (full page load) when crossing apps
Build-time packagesTeams publish npm packages; the shell bundles themShared widgets, not independent deploysEvery change needs a shell rebuild and release — this is not independent deployment
Runtime JS: Module Federation 2.0Host loads remotes’ exposed modules at runtime, sharing singletonsSeveral teams must render on the same page or keep SPA navigation across areasVersion skew, runtime failure modes, harder debugging
Runtime JS: single-spa + import mapsRoot config mounts framework apps per routeMixed frameworks during a migrationLifecycle glue per app; extra runtime
Server or edge HTML compositionServer-side includes, ESI, or an edge worker stitches HTML fragmentsContent-heavy pages, SEO-critical, little client stateFragment caching and hydration coordination
IframesEach fragment is a separate documentUntrusted or third-party content, hard isolation, legacy embeddingRouting, focus, resizing, accessibility and performance all get harder

Rules of thumb:

  • Start with the default. Escalate to Module Federation only for a concrete same-page requirement, and record it in the decision.
  • Next.js: use Multi-Zones (separate apps per path set, assetPrefix per zone, rewrites in the main app). Link across zones with a plain <a>, not <Link>, because <Link> prefetches and soft-navigates. The @module-federation/nextjs-mf plugin supports only the Pages Router and has been announced end-of-life; do not start new work on it.
  • Vite: @module-federation/vite. Webpack and Rspack: @module-federation/enhanced (/webpack, /rspack entry points) or the Rsbuild plugin.

For Module Federation host and remote configuration, the runtime API, manifests, and failure handling, read references/module-federation.md when the chosen approach is runtime composition.

5. Shared dependencies and the design system

Classify every dependency:

ClassExamplesRule
Must be a single instancereact, react-dom, router, design-system context providers, i18n runtimeShared as singleton with a requiredVersion range; one version policy across apps
Should share when compatibleDate libraries, data-fetching clientsShared without singleton; duplicates allowed on major mismatch
App-privateFeature librariesBundled per app; never shared
  • Version policy: a central file (or Renovate group) pins the allowed major for each singleton. Upgrading a singleton major is a coordinated, scheduled change — the one place where independence ends. Plan for it.
  • Design system: a versioned package (tokens as CSS custom properties plus components). Remotes must not ship global resets or element selectors (body, h1, *).
  • CSS isolation: CSS Modules or a per-app class prefix by default; wrap app styles in a cascade layer (@layer app-billing) so ordering is predictable. Shadow DOM isolates fully but blocks shared tokens unless they are custom properties, and complicates portals, focus, and third-party widgets.

6. Routing ownership and communication

Routing:

  • The shell (or proxy config) owns the top-level route table: prefix to app. Each app owns everything under its prefix, including its own 404 inside the prefix.
  • The route table is a contract: keep it in one file, review changes, and test that each prefix resolves to the right app.
  • Deep links and refresh must work on every route. Test them, not just in-app navigation.
  • With a runtime host, the host owns the history instance; remotes receive a base path and navigate through a function the host passes in, never by writing to window.history directly.

Communication, in order of preference:

  1. URL and query string (shareable, survives reload).
  2. Props or a small API the host passes when mounting a remote.
  3. Typed custom DOM events for fire-and-forget notifications.
  4. Backend as the source of truth (both apps read the same API).
// packages/contracts/src/events.ts — published, versioned, owned by the platform team
export type CartItemAdded = { v: 1; sku: string; quantity: number };

export const CART_ITEM_ADDED = "shop:cart:item-added" as const;

export function emitCartItemAdded(detail: CartItemAdded) {
  window.dispatchEvent(new CustomEvent<CartItemAdded>(CART_ITEM_ADDED, { detail }));
}
  • Namespace event names by domain; include a version field in the payload; add fields, never repurpose them.
  • Auth: one session cookie on the shared domain, read by each app’s backend. Never pass tokens through events or localStorage shared between apps.

7. Performance budget

Composition must not cost the user. Budget per app and for the composed page:

  • JS shipped per route (compressed) and the count of framework copies — exactly one.
  • No request waterfall: host, then remote entry, then the remote’s chunks. Preload remote entries for the current route and use the manifest so assets are known up front.
  • Core Web Vitals at p75 for each app’s routes, tracked separately per app so a regression has an owner.
  • Check for duplicate packages in each build (bundle analyzer, npm ls <pkg>); a second React or design-system copy is a release blocker.
  • Enforce in CI with a size checker on each app’s build output; fail the build on breach.

8. Independent deploys and versioning

  • Publish every remote build to an immutable, versioned path (/billing/2026-10-14-ab12cd/), long-cache those assets, and point to the live version through a small, short-cache manifest (or the MF mf-manifest.json URL).
  • Rollback is repointing the manifest, not a rebuild. Confirm with the user before repointing production.
  • Contracts (exposed module props, events, routes) change backward-compatibly: add, then migrate consumers, then remove in a later release.
  • Expect skew: an open tab can run an old host with a new remote. Exposed modules must tolerate missing optional props; a failed chunk load should trigger a reload prompt.
  • Contract-test exposed modules (render with the props the host passes) in the remote’s own CI, and run a smoke test of the composed app against production remotes after each deploy.

Gotchas

  • Two copies of React load when a remote is built without the shared singleton or the version ranges do not overlap. Symptoms: “Invalid hook call”, context not found.
  • eager: true on shared modules puts them in the entry chunk and inflates every app; use an async boundary (import("./bootstrap")) instead.
  • A remote’s global CSS reset silently restyles the host. Lint remotes for element selectors.
  • Cross-app navigation through <Link> or a router that does not know the other app produces 404s or blank screens. Plain links across app boundaries.
  • Analytics and error tracking need the app name and version on every event, or regressions cannot be attributed to a team.
  • Local development drifts from production when each team runs only its app. Provide a shell that can point at production remotes plus one local remote.

Verification

  1. Block one remote’s entry in DevTools (Network request blocking): the page shows the fallback and the rest of the app keeps working.
  2. In the Network or Sources panel, confirm exactly one copy of the framework and the design system loads on a composed page.
  3. Deep-link and hard-refresh one route per app; each resolves without a client-side 404.
  4. Deploy one remote alone and confirm the host picks it up without a host release; repoint the manifest to the previous version and confirm rollback.
  5. Run the per-app size and Web Vitals budgets in CI; a deliberately oversized build must fail.

If a check fails, fix the contract (shared config, route table, fallback) before adding more apps.

Output contract

Deliver the decision as a short record:

## Frontend split decision — <product>, <date>
**Topology test:** <teams, release blocking evidence, route mapping, platform owner>
**Decision:** Modular monolith | Route-level apps | Module Federation | <other>
**Reason:** <one paragraph; name the concrete requirement that forced runtime composition, if any>
**Ownership map:** /billing/* → Payments team (app: billing) ...
**Shared singletons and version policy:** react ^19, router, design-system ^4 ...
**Contracts:** route table file, events package, exposed modules
**Budgets:** <JS per route, Web Vitals targets per app>
**Deploy and rollback:** <asset paths, manifest location, rollback step>
**Revisit when:** <trigger, e.g. teams merge, migration finishes>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/module-federation.md 176 lines · 6.3 KB
# Module Federation 2.0 — configuration and failure handling

Read this only after the decision record says "runtime composition". Package versions
move quickly; confirm with `npm view @module-federation/enhanced version` and
`npm view @module-federation/vite version` before pinning.

## Packages by bundler

| Bundler | Package and import |
|---|---|
| Webpack 5 | `import { ModuleFederationPlugin } from "@module-federation/enhanced/webpack"` |
| Rspack | `import { ModuleFederationPlugin } from "@module-federation/enhanced/rspack"` |
| Rsbuild | `@module-federation/rsbuild-plugin` (`createModuleFederationConfig`) |
| Vite | `import { federation } from "@module-federation/vite"` |
| Runtime only (no build plugin) | `@module-federation/enhanced/runtime` (`createInstance`, `loadRemote`) |
| Next.js | Not recommended: `@module-federation/nextjs-mf` is Pages Router only and end-of-life. Use Multi-Zones. |

## Remote (producer) — webpack or Rspack

```js
// billing/webpack.config.js
const { ModuleFederationPlugin } = require("@module-federation/enhanced/webpack");

module.exports = {
  output: { publicPath: "auto", uniqueName: "billing" },
  plugins: [
    new ModuleFederationPlugin({
      name: "billing",
      filename: "remoteEntry.js",
      manifest: true,                       // emits mf-manifest.json next to remoteEntry.js
      exposes: {
        "./BillingPage": "./src/expose/BillingPage.tsx",   // expose a thin wrapper, not internals
      },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
        "@acme/design-system": { singleton: true, requiredVersion: "^4.0.0" },
      },
      dts: true,                            // generate and publish types for the host
    }),
  ],
};
```

- Expose a small number of entry points (one per page or widget), each a wrapper that
  receives props from the host. Exposing internal components turns them into a public API.
- `publicPath: "auto"` lets chunks resolve relative to wherever the remote entry was
  served from, which keeps versioned asset paths working.

## Host (consumer)

```js
new ModuleFederationPlugin({
  name: "shell",
  remotes: {
    billing: "billing@https://cdn.example.com/billing/current/mf-manifest.json",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^19.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
    "@acme/design-system": { singleton: true, requiredVersion: "^4.0.0" },
  },
});
```

Async boundary so shared modules negotiate before the app runs (avoid `eager: true`):

```ts
// src/index.ts
import("./bootstrap");
```

## Vite

```ts
// remote/vite.config.ts
import { defineConfig } from "vite";
import { federation } from "@module-federation/vite";

export default defineConfig({
  plugins: [
    federation({
      name: "billing",
      filename: "remoteEntry.js",
      manifest: true,
      exposes: { "./BillingPage": "./src/expose/BillingPage.tsx" },
      shared: { react: { singleton: true }, "react-dom": { singleton: true } },
    }),
  ],
});

// host/vite.config.ts — remotes as objects; set type explicitly
federation({
  name: "shell",
  remotes: {
    billing: {
      type: "module",
      name: "billing",
      entry: "https://cdn.example.com/billing/current/remoteEntry.js",
      entryGlobalName: "billing",
      shareScope: "default",
    },
  },
  shared: { react: { singleton: true }, "react-dom": { singleton: true } },
});
```

Omitting `type` on an object remote defaults to `"var"` with a warning; mismatched
formats are a common cause of "remote entry loaded but container is undefined".

## Shared options that matter

| Option | Default | Use |
|---|---|---|
| `singleton` | `false` | One instance across host and remotes; the highest version wins and lower-version parties log a warning |
| `requiredVersion` | from `package.json` | Range a party accepts; a mismatch logs a warning |
| `strictVersion` | webpack semantics: on only for non-singletons with a local fallback | Reject an incompatible shared version instead of warning; set it explicitly on singletons where two versions corrupt state |
| `eager` | `false` | Puts the module in the entry chunk; avoid except for a host without an async boundary |
| `import: false` | — | Do not bundle a fallback; the host must provide it |

Treat warnings about shared version mismatches as build failures in CI.

## Runtime API (dynamic remotes)

Use when the list of remotes comes from configuration (per tenant, per feature flag):

```ts
import { createInstance } from "@module-federation/enhanced/runtime";

const mf = createInstance({
  name: "shell",
  remotes: [{ name: "billing", entry: "https://cdn.example.com/billing/current/mf-manifest.json" }],
});

const mod = await mf.loadRemote<{ default: React.ComponentType<BillingProps> }>("billing/BillingPage");
```

With the build plugin already active, use `getInstance()` plus `registerRemotes([...])`
rather than creating a second instance. `init` exists but is marked "use with caution";
prefer `createInstance` for pure runtime setups. `preloadRemote` fetches a remote's entry
and assets ahead of navigation to avoid the host → entry → chunk waterfall.

## Failure handling in the host

```tsx
const BillingPage = React.lazy(() =>
  import("billing/BillingPage").catch(() => ({ default: BillingUnavailable })),
);

<ErrorBoundary fallback={<BillingUnavailable />}>
  <Suspense fallback={<PageSkeleton />}>
    <BillingPage basePath="/billing" navigate={navigate} user={user} />
  </Suspense>
</ErrorBoundary>
```

- Catch the dynamic import (network failure) and wrap rendering in an error boundary
  (runtime failure inside the remote). Both are needed.
- `BillingUnavailable` explains the area is temporarily unavailable and offers a retry;
  it never blanks the shell.
- On `ChunkLoadError` after a deploy, prompt for a reload rather than retrying forever.

## Types across repos

`dts: true` generates types for exposed modules; the host downloads them during
development. If types are unavailable in CI, declare the module explicitly:

```ts
// shell/src/remotes.d.ts
declare module "billing/BillingPage" {
  const BillingPage: React.ComponentType<{ basePath: string; navigate: (to: string) => void }>;
  export default BillingPage;
}
```

Keep the props type in the shared contracts package so both sides compile against it.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc