Micro Frontends Cheat Sheet
Covers composition patterns for micro-frontends including Module Federation, iframe and web-component integration, and cross-team challenges.
Composition Patterns
The main ways teams stitch independent frontends together.
- Build-time integration- Micro-apps published as npm packages and composed at the host's build time
- Run-time via JS- Module Federation / import maps load remote bundles dynamically in the browser
- Run-time via iframe- Each micro-frontend runs isolated in an iframe; strong isolation, weaker UX integration
- Server-side composition- Edge/server stitches HTML fragments from multiple backends before responding
- Web Components as the contract- Each team ships a custom element; the host page just drops in the tag
Module Federation (Webpack 5)
Sharing a remote widget between independently deployed apps.
// remote app's webpack.config.jsconst { ModuleFederationPlugin } = require('webpack').container;module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'checkout', filename: 'remoteEntry.js', exposes: { './CheckoutWidget': './src/CheckoutWidget' }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }), ],};// host app's webpack.config.jsnew ModuleFederationPlugin({ name: 'shell', remotes: { checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js' }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } },});// host consumes it like a normal dynamic importconst CheckoutWidget = React.lazy(() => import('checkout/CheckoutWidget'));
Iframe & Web Component Integration
Two lighter-weight isolation strategies.
<!-- Iframe isolation: strongest sandboxing, own JS/CSS context --><iframe src="https://checkout.example.com/widget" title="Checkout" style="border:0;width:100%"></iframe><!-- Web Component contract: each team owns a custom element --><script type="module" src="https://cdn.example.com/checkout-widget.js"></script><checkout-widget cart-id="abc123"></checkout-widget><!-- Cross-fragment communication via CustomEvent, not shared globals --><script> document.querySelector('checkout-widget') .addEventListener('checkout:complete', (e) => console.log(e.detail.orderId));</script>
Common Challenges
What makes micro-frontends hard in practice.
- Shared state- Avoid global mutable state; use custom events or a thin shared event bus instead
- CSS isolation- Shadow DOM, CSS Modules, or strict naming conventions to prevent style bleed
- Duplicate dependencies- Without shared/singleton config, each fragment may ship its own React, bloating bundles
- Consistent UX- A shared component library keeps independently-deployed fragments visually cohesive
- Versioning & deployment- Independent pipelines per team need a strategy for coordinating breaking changes
- Performance- Multiple frameworks/bundles loaded at once can hurt Time to Interactive
Client-Side Orchestration with single-spa
Registering independently-deployed applications with lifecycle hooks and activity functions.
import { registerApplication, start } from 'single-spa';registerApplication({ name: 'checkout', app: () => System.import('checkout'), activeWhen: (location) => location.pathname.startsWith('/checkout'), customProps: { authToken: getAuthToken() },});registerApplication({ name: 'nav', app: () => System.import('nav'), activeWhen: () => true, // always mounted});// Each app exports bootstrap/mount/unmount so single-spa can// swap it in/out without a full page reloadexport const bootstrap = async (props) => { /* one-time init */ };export const mount = async (props) => { /* render into props.domElement */ };export const unmount = async (props) => { /* clean up listeners, unmount React tree */ };start({ urlRerouteOnly: true });
Runtime Remote Resolution (Module Federation 2.0)
Resolving remote entry URLs at runtime instead of baking them into the webpack config, so hosts can point at different remote versions per environment.
import { init, loadRemote } from '@module-federation/enhanced/runtime';init({ name: 'shell', remotes: [ { name: 'checkout', entry: await resolveRemoteEntry('checkout'), // e.g. from a manifest service }, ], shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, },});// Load on demand; falls back gracefully if the remote is unreachabletry { const { default: CheckoutWidget } = await loadRemote('checkout/Widget'); mount(CheckoutWidget);} catch (err) { renderFallbackCheckout(); // never let one remote's outage break the shell}
Routing Ownership Strategies
Who decides which micro-frontend renders for a given URL.
- Shell-owned client routing- A top-level router (single-spa, custom) maps path prefixes to registered apps
- Edge-side routing- CDN/reverse proxy routes whole paths to different origins per micro-frontend, no shared shell JS
- Server-side includes (SSI/ESI)- Edge stitches HTML fragments from multiple origins into one response before caching
- Nested/sub-routing- Shell owns the top-level segment; each micro-frontend owns its own sub-routes internally
- Deep-linking contract- Micro-frontends must treat their mount path as an external API — changing it breaks bookmarks
- History API conflicts- Only one app should call history.pushState per navigation; wrap it in a shared router utility
Isolating a Failing Remote
Wrapping a federated remote in an error boundary so one team's bug degrades gracefully instead of crashing the shell.
class RemoteErrorBoundary extends React.Component { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, info) { reportToMonitoring({ remote: this.props.name, error, info }); } render() { if (this.state.hasError) { return this.props.fallback ?? <div>{this.props.name} is unavailable</div>; } return this.props.children; }}// Usage: each remote gets its own boundary so a crash in Checkout// never takes down Nav or the rest of the page<RemoteErrorBoundary name="checkout"> <React.Suspense fallback={<Spinner />}> <CheckoutWidget /> </React.Suspense></RemoteErrorBoundary>
Resist letting micro-frontends share a global state store (like one Redux instance) across team boundaries — it recreates a tightly-coupled monolith with extra network hops. Communicate through custom events or a thin pub/sub layer instead.