Frontend Architecture & Patterns
17 min read

Micro-Frontends Architecture: Composition, Isolation, and Delivery

Learn how to scale frontend development with microfrontends, enabling team autonomy, independent deployments, and domain-driven boundaries for large-scale applications.

Microfrontends break large frontend applications into smaller, independent pieces that can be developed, deployed, and scaled separately.

  • Team Autonomy: Each team owns their microfrontend end-to-end
  • Technology Freedom: Teams can choose different frameworks (React, Vue, Angular, Svelte)
  • Independent Deployments: Deploy without coordinating with other teams
  • Domain-Driven Design: Organized around business domains, not technical layers
  • Client-Side: Browser assembly using Module Federation, Web Components, iframes
  • Server-Side: Server assembly using SSR frameworks, Server-Side Includes
  • Edge-Side: CDN assembly using Cloudflare Workers, ESI, Lambda@Edge
  • Iframes: Maximum isolation, complex communication via postMessage
  • Web Components: Framework-agnostic, encapsulated UI widgets
  • Module Federation: Dynamic code sharing, dependency optimization
  • Custom Events: Simple publish-subscribe communication
  • Independent CI/CD pipelines for each microfrontend
  • Local state first - each microfrontend manages its own state
  • URL-based state for sharing ephemeral data
  • Custom events for cross-microfrontend communication
  • Client-Side: High interactivity, complex state sharing, SPA requirements
  • Edge-Side: Global performance, low latency, high availability needs
  • Server-Side: SEO-critical, initial load performance priority
  • Iframes: Legacy integration, security sandboxing requirements
  • Cross-cutting concerns: State management, routing, user experience
  • Performance overhead: Multiple JavaScript bundles, network requests
  • Complexity: Requires mature CI/CD, automation, and tooling
  • Team coordination: Shared dependencies, versioning, integration testing

A successful microfrontend implementation is built on a foundation of core principles that ensure scalability and team independence.

Each team should have the freedom to choose the technology stack best suited for their specific domain, without being constrained by the choices of other teams. Custom Elements are often used to create a neutral interface between these potentially disparate stacks.

To prevent the tight coupling that plagues monoliths, microfrontends should not share a runtime. Each should be built as an independent, self-contained application, avoiding reliance on shared state or global variables.

A cornerstone of the architecture is the ability for each team to deploy their microfrontend independently. This decouples release cycles, accelerates feature delivery, and empowers teams with true ownership.

Microfrontends should be modeled around business domains, not technical layers. This ensures that teams are focused on delivering business value and that the boundaries between components are logical and clear.

Monolithic Frontend Architecture

Single Codebase

Shared Dependencies

Tight Coupling

Coordinated Deployments

Monolithic frontend architecture showing the tight coupling and coordinated deployments that microfrontends aim to solve

Microfrontend Architecture

Team A - React

Independent Deployments

Team B - Vue

Team C - Angular

Team D - Svelte

Domain Boundaries

Technology Freedom

Team Autonomy

Microfrontend architecture showing independent deployments, domain boundaries, technology freedom, and team autonomy

The method by which independent microfrontends are stitched together into a cohesive user experience is known as composition. The location of this assembly process is a primary architectural decision, leading to three distinct models.

Composition StrategyPrimary LocationKey TechnologiesIdeal Use Case
Client-SideUser’s BrowserModule Federation, iframes, Web Components, single-spaHighly interactive, complex Single-Page Applications (SPAs) where teams are familiar with the frontend ecosystem
Server-SideOrigin ServerServer-Side Includes (SSI), SSR Frameworks (e.g., Next.js)SEO-critical applications where initial load performance is paramount and state-sharing complexity is high
Edge-SideCDN / Edge NetworkESI, Cloudflare Workers, AWS Lambda@EdgeApplications with global audiences that require high availability, low latency, and the ability to offload scalability challenges to the CDN provider

Edge-Side Composition

Server-Side Composition

Client-Side Composition

Browser

Application Shell

Module Federation

Web Components

Iframes

Origin Server

SSR Framework

Server-Side Includes

CDN Edge

Cloudflare Workers

ESI

Lambda@Edge

User Request

Three composition strategies showing client-side, server-side, and edge-side approaches for assembling microfrontends

The choice of composition model dictates the available integration techniques, each with its own set of trade-offs regarding performance, isolation, and developer experience.

In this model, an application shell is loaded in the browser, which then dynamically fetches and renders the various microfrontends.

Iframes offer the strongest possible isolation in terms of styling and JavaScript execution. This makes them an excellent choice for integrating legacy applications or third-party content where trust is low. However, they introduce complexity in communication (requiring postMessage APIs) and can create a disjointed user experience.

20 collapsed lines
<!-- Example: Iframe-based microfrontend integration -->
<div class="app-shell">
<header>
<h1>E-commerce Platform</h1>
</header>
<main>
<!-- Product catalog microfrontend -->
<iframe
src="https://catalog.microfrontend.com"
id="catalog-frame"
style="width: 100%; height: 600px; border: none;"
>
</iframe>
<!-- Shopping cart microfrontend -->
<iframe src="https://cart.microfrontend.com" id="cart-frame" style="width: 300px; height: 400px; border: none;">
</iframe>
</main>
</div>
<script>
// Communication between iframes using postMessage
document.getElementById("catalog-frame").contentWindow.postMessage(
{
type: "ADD_TO_CART",
productId: "12345",
},
"https://catalog.microfrontend.com",
)
window.addEventListener("message", (event) => {
if (event.origin !== "https://cart.microfrontend.com") return
if (event.data.type === "CART_UPDATED") {
console.log("Cart updated:", event.data.cart)
}
})
</script>

By using a combination of Custom Elements and the Shadow DOM, Web Components provide a standards-based, framework-agnostic way to create encapsulated UI widgets. They serve as a neutral interface, allowing a React-based shell to seamlessly host a component built in Vue or Angular.

product-card.js
9 collapsed lines
// Example: Custom Element for a product card microfrontend
class ProductCard extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" })
}
connectedCallback() {
this.render()
}
render() {
this.shadowRoot.innerHTML = `
23 collapsed lines
<style>
.product-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 8px;
max-width: 300px;
}
.product-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.product-price {
color: #e44d26;
font-size: 20px;
font-weight: bold;
}
.add-to-cart-btn {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
</style>
<div class="product-card">
<div class="product-title">${this.getAttribute("title")}</div>
<div class="product-price">$${this.getAttribute("price")}</div>
<button class="add-to-cart-btn" onclick="this.addToCart()">
Add to Cart
</button>
</div>
`
}
// Key pattern: Custom events enable framework-agnostic communication
addToCart() {
this.dispatchEvent(
new CustomEvent("addToCart", {
detail: {
productId: this.getAttribute("product-id"),
title: this.getAttribute("title"),
price: this.getAttribute("price"),
},
bubbles: true,
}),
)
}
}
customElements.define("product-card", ProductCard)

A revolutionary feature in Webpack 5+, Module Federation allows a JavaScript application to dynamically load code from a completely separate build at runtime. It enables true code sharing between independent applications.

How it works: A host application consumes code from a remote application. The remote exposes specific modules (like components or functions) via a remoteEntry.js file. Crucially, both can define shared dependencies (e.g., React), allowing the host and remote to negotiate and use a single version, preventing the library from being downloaded multiple times.

webpack.config.js (Host)
4 collapsed lines
// Host application webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin")
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
// Remote entry points - each microfrontend exposes its modules via remoteEntry.js
productCatalog: "productCatalog@http://localhost:3001/remoteEntry.js",
shoppingCart: "shoppingCart@http://localhost:3002/remoteEntry.js",
},
shared: {
// singleton: true ensures only one React instance across all microfrontends
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
}
webpack.config.js (Remote)
4 collapsed lines
// Remote application webpack.config.js
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin")
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "productCatalog",
filename: "remoteEntry.js",
exposes: {
// Components exposed to consuming applications
"./ProductList": "./src/components/ProductList",
"./ProductCard": "./src/components/ProductCard",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
}
App.jsx (Host)
// Host application consuming remote components
import React, { Suspense } from "react"
// Dynamic imports load remote microfrontends at runtime
const ProductList = React.lazy(() => import("productCatalog/ProductList"))
const ShoppingCart = React.lazy(() => import("shoppingCart/ShoppingCart"))
function App() {
return (
<div className="app">
<Suspense fallback={<div>Loading products...</div>}>
<ProductList />
</Suspense>
<Suspense fallback={<div>Loading cart...</div>}>
<ShoppingCart />
</Suspense>
</div>
)
}

Use Case: This is the dominant technique for building complex, interactive SPAs that feel like a single, cohesive application. It excels at optimizing bundle sizes through dependency sharing and enables rich, integrated state management. The trade-off is tighter coupling at the JavaScript level, requiring teams to coordinate on shared dependency versions.

This hybrid model moves the assembly logic from the origin server to the CDN layer, physically closer to the end-user.

A legacy XML-based markup language, ESI allows an edge proxy to stitch a page together from fragments with different caching policies. An <esi:include> tag in the HTML instructs the ESI processor to fetch and inject content from another URL.

<!-- Example: ESI-based page assembly -->
<!DOCTYPE html>
<html>
<head>
<title>E-commerce Platform</title>
<link rel="stylesheet" href="/styles/main.css" />
</head>
<body>
<header>
<esi:include src="https://header.microfrontend.com" />
</header>
<main>
<div class="product-catalog">
<esi:include src="https://catalog.microfrontend.com/products" />
</div>
<aside class="shopping-cart">
<esi:include src="https://cart.microfrontend.com" />
</aside>
</main>
<footer>
<esi:include src="https://footer.microfrontend.com" />
</footer>
</body>
</html>

While effective for caching, ESI is limited by its declarative nature and inconsistent vendor support.

The modern successor to ESI, programmable edge environments provide a full JavaScript runtime on the CDN. Using APIs like Cloudflare’s HTMLRewriter, a worker can stream an application shell, identify placeholder elements, and stream microfrontend content directly into them from different origins.

worker.js
5 collapsed lines
// Example: Cloudflare Worker for edge-side composition
export default {
async fetch(request, env) {
const url = new URL(request.url)
// Fetch the application shell from origin
const shellResponse = await fetch("https://shell.microfrontend.com" + url.pathname)
// Fetch microfrontend fragments in parallel
const [headerHtml, catalogHtml, cartHtml] = await Promise.all([
fetch("https://header.microfrontend.com").then((r) => r.text()),
fetch("https://catalog.microfrontend.com/products").then((r) => r.text()),
fetch("https://cart.microfrontend.com").then((r) => r.text()),
])
// Use HTMLRewriter to inject microfrontend content into placeholders
return new HTMLRewriter()
.on('[data-microfrontend="header"]', {
element(el) {
el.replace(headerHtml, { html: true })
},
})
.on('[data-microfrontend="catalog"]', {
element(el) {
el.replace(catalogHtml, { html: true })
},
})
.on('[data-microfrontend="cart"]', {
element(el) {
el.replace(cartHtml, { html: true })
},
})
.transform(shellResponse)
},
}

This approach offers the performance benefits of server-side rendering with the scalability of a global CDN. A powerful pattern called “Fragment Piercing” even allows for the incremental modernization of legacy client-side apps by server-rendering new microfrontends at the edge and “piercing” them into the existing application’s DOM.

A core tenet of microfrontends is independent deployability, which necessitates a robust and automated CI/CD strategy.

Each microfrontend must have its own dedicated CI/CD pipeline, allowing its owning team to build, test, and deploy without coordinating with others. This is fundamental to achieving team autonomy.

Team C - User Profile

Team B - Shopping Cart

Team A - Product Catalog

Code Push

Build & Test

Deploy to Staging

Integration Tests

Deploy to Production

Code Push

Build & Test

Deploy to Staging

Integration Tests

Deploy to Production

Code Push

Build & Test

Deploy to Staging

Integration Tests

Deploy to Production

Independent Deployments

Independent deployment pipelines showing how each team can build, test, and deploy their microfrontend without coordinating with others

Teams often face a choice between a single monorepo or multiple repositories (polyrepo). A monorepo can simplify dependency management and ensure consistency, but it can also reduce team autonomy and create tight coupling if not managed carefully.

.github/workflows/deploy-catalog.yml
6 collapsed lines
# Example: GitHub Actions workflow for independent deployment
name: Deploy Product Catalog Microfrontend
on:
push:
branches: [main]
paths:
# Key pattern: Only trigger when this specific microfrontend changes
- "microfrontends/product-catalog/**"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
23 collapsed lines
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: "microfrontends/product-catalog/package-lock.json"
- name: Install dependencies
run: |
cd microfrontends/product-catalog
npm ci
- name: Run tests
run: |
cd microfrontends/product-catalog
npm test
- name: Build application
run: |
cd microfrontends/product-catalog
npm run build
# Independent deployment - no coordination with other teams
- name: Deploy to staging
run: npm run deploy:staging
working-directory: microfrontends/product-catalog
- name: Run integration tests
run: npm run test:integration
- name: Deploy to production
if: success()
run: npm run deploy:production
working-directory: microfrontends/product-catalog

A mature automation culture is non-negotiable.

Selective Builds: CI/CD systems should be intelligent enough to identify and build only the components that have changed, avoiding unnecessary full-application rebuilds.

Versioning: Shared dependencies and components must be strictly versioned to prevent conflicts and allow teams to adopt updates at their own pace.

Infrastructure: Container orchestration platforms like Kubernetes are often used to manage and scale the various services that constitute the microfrontend ecosystem.

While decomposition solves many problems, it introduces new challenges, particularly around state, routing, and user experience.

Managing state is one of the most complex aspects of a microfrontend architecture. The primary goal is to maintain isolation and avoid re-introducing the tight coupling the architecture was meant to solve.

The default and most resilient pattern is for each microfrontend to manage its own state independently.

ProductCatalog.jsx
3 collapsed lines
// Example: Local state management in a React microfrontend
import React, { useState, useEffect } from "react"
function ProductCatalog() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({})
useEffect(() => {
fetchProducts(filters)
14 collapsed lines
}, [filters])
const fetchProducts = async (filters) => {
setLoading(true)
try {
const response = await fetch(`/api/products?${new URLSearchParams(filters)}`)
const data = await response.json()
setProducts(data)
} catch (error) {
console.error("Failed to fetch products:", error)
} finally {
setLoading(false)
}
}
// Key pattern: Sync local state to URL for shareability
const handleFilterChange = (newFilters) => {
setFilters(newFilters)
window.history.replaceState(null, "", `?${new URLSearchParams(newFilters)}`)
}
return (
<div className="product-catalog">
<FilterPanel filters={filters} onFilterChange={handleFilterChange} />
{loading ? <div>Loading...</div> : <ProductGrid products={products} />}
</div>
)
}

For ephemeral state that needs to be shared across fragments (e.g., search filters), the URL is the ideal, stateless medium.

url-state-manager.js
6 collapsed lines
// Example: URL-based state management
class URLStateManager {
constructor() {
this.listeners = new Set()
window.addEventListener("popstate", this.handlePopState.bind(this))
}
// Key pattern: URL as the source of truth for cross-microfrontend state
setState(key, value) {
const url = new URL(window.location)
if (value === null || value === undefined) {
url.searchParams.delete(key)
} else {
url.searchParams.set(key, JSON.stringify(value))
}
window.history.pushState(null, "", url)
this.notifyListeners()
}
getState(key) {
const url = new URL(window.location)
const value = url.searchParams.get(key)
return value ? JSON.parse(value) : null
}
subscribe(listener) {
10 collapsed lines
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
notifyListeners() {
this.listeners.forEach((listener) => listener())
}
handlePopState() {
this.notifyListeners()
}
}
// Usage across microfrontends - any microfrontend can read/write
const stateManager = new URLStateManager()
stateManager.setState("category", "electronics")
const category = stateManager.getState("category")

For client-side communication after composition, native browser events provide a simple and effective publish-subscribe mechanism, allowing fragments to communicate without direct knowledge of one another.

event-bus.js
26 collapsed lines
// Example: Event-based communication between microfrontends
class MicrofrontendEventBus {
constructor() {
this.events = {}
}
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
}
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach((callback) => callback(data))
}
}
off(event, callback) {
if (this.events[event]) {
this.events[event] = this.events[event].filter((cb) => cb !== callback)
}
}
}
window.microfrontendEvents = new MicrofrontendEventBus()
// Key pattern: Loose coupling via pub-sub
// Product catalog emits events (doesn't know who listens)
function addToCart(product) {
window.microfrontendEvents.emit("addToCart", {
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
})
}
// Shopping cart subscribes (doesn't know who publishes)
window.microfrontendEvents.on("addToCart", (productData) => {
updateCart(productData)
})

For truly global state like user authentication, a shared store (e.g., Redux) can be used. However, this should be a last resort, as it introduces a strong dependency between fragments and the shared module, reducing modularity.

shared-store.js
4 collapsed lines
// Example: Shared Redux store (use sparingly - reduces modularity)
import { createStore, combineReducers } from "redux"
// Shared user state - authentication is a valid use case for shared state
const userReducer = (state = null, action) => {
switch (action.type) {
case "SET_USER":
9 collapsed lines
return action.payload
case "LOGOUT":
return null
default:
return state
}
}
// Shared cart state - consider URL-based or event-based alternatives first
const cartReducer = (state = [], action) => {
switch (action.type) {
14 collapsed lines
case "ADD_TO_CART":
const existingItem = state.find((item) => item.id === action.payload.id)
if (existingItem) {
return state.map((item) => (item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item))
}
return [...state, { ...action.payload, quantity: 1 }]
case "REMOVE_FROM_CART":
return state.filter((item) => item.id !== action.payload)
default:
return state
}
}
const rootReducer = combineReducers({ user: userReducer, cart: cartReducer })
// Warning: All microfrontends now depend on this store version
window.sharedStore = createStore(rootReducer)

Routing logic is intrinsically tied to the composition model.

In architectures using an application shell (common with Module Federation or single-spa), a global router within the shell manages navigation between different microfrontends, while each microfrontend can handle its own internal, nested routes.

root-config.js
// Example: Client-side routing with single-spa
import { registerApplication, start } from "single-spa"
// Key pattern: Route-based microfrontend mounting
// Each microfrontend mounts/unmounts based on URL patterns
registerApplication({
name: "product-catalog",
app: () => import("./product-catalog"),
activeWhen: ["/products", "/"],
customProps: { domElement: document.getElementById("product-catalog-container") },
})
registerApplication({
name: "shopping-cart",
app: () => import("./shopping-cart"),
12 collapsed lines
activeWhen: ["/cart"],
customProps: { domElement: document.getElementById("shopping-cart-container") },
})
registerApplication({
name: "user-profile",
app: () => import("./user-profile"),
activeWhen: ["/profile"],
customProps: { domElement: document.getElementById("user-profile-container") },
})
start()

In server or edge-composed systems, routing is typically handled by the webserver or edge worker. Each URL corresponds to a page that is assembled from a specific set of fragments, simplifying the client-side logic at the cost of a full network round trip for each navigation.

pages/products/[category].js
// Example: Server-side routing with Next.js
export default function ProductCategory({ products, category }) {
return (
<div className="product-category-page">
<h1>{category} Products</h1>
{/* Microfrontend components composed server-side */}
<ProductCatalog products={products} />
<ShoppingCart />
</div>
)
}
6 collapsed lines
// Key pattern: Data fetched at request time, page assembled server-side
export async function getServerSideProps({ params }) {
const { category } = params
const products = await fetchProductsByCategory(category)
return { props: { products, category } }
}

The “best” microfrontend approach is context-dependent. The decision should be driven by application requirements, team structure, and performance goals.

  • Your application is a highly interactive, complex SPA that needs to feel like a single, seamless product
  • Multiple fragments need to share complex state
  • Optimizing the total JavaScript payload via dependency sharing is a key concern
  • Teams are familiar with the frontend ecosystem and can coordinate on shared dependencies
  • Your primary goals are global low latency, high availability, and superior initial load performance
  • You’re building e-commerce sites, news portals, or any application serving a geographically diverse audience
  • Offloading scalability to a CDN is a strategic advantage
  • You need to incrementally modernize legacy applications
  • SEO and initial page load time are the absolute highest priorities
  • You’re building content-heavy sites with less dynamic interactivity
  • Delivering a fully-formed HTML document to web crawlers is critical
  • State-sharing complexity is high and you want to avoid client-side coordination
  • You need to integrate a legacy application into a modern shell
  • You’re embedding untrusted third-party content
  • The unparalleled security sandboxing of iframes is required
  • You need complete isolation between different parts of the application

High Interactivity & Complex State

Global Performance & Low Latency

SEO & Initial Load Performance

Security & Legacy Integration

Start: Choose Microfrontend Strategy

What's your primary goal?

Client-Side Composition

Edge-Side Composition

Server-Side Composition

Iframe Integration

Module Federation

Web Components

single-spa

Cloudflare Workers

ESI

Lambda@Edge

SSR Frameworks

Server-Side Includes

postMessage API

Cross-Origin Communication

Decision tree for choosing the right microfrontend composition strategy based on primary goals and requirements

Microfrontends enable scalable frontend development but introduce complexity that must be justified by organizational needs. The architecture works best when:

  • Multiple teams need to deploy independently without coordination
  • Technology diversity is required across different parts of the application
  • Domain boundaries are clear and stable

The composition strategy should match your constraints: client-side for SPAs with complex state sharing, edge-side for global performance requirements, server-side for SEO-critical applications.

Microfrontends are fundamentally an organizational decision. The technical implementation follows from how teams are structured, how releases are managed, and what trade-offs are acceptable. Start with the simplest approach that enables independent deployment, then add complexity only when needed.

Read more

  • Previous

    E-commerce SSG to SSR Migration: Strategy and Pitfalls

    Platform Engineering / Platform Migrations 24 min read

    This comprehensive guide outlines the strategic migration from Static Site Generation (SSG) to Server-Side Rendering (SSR) for enterprise e-commerce platforms. Drawing from real-world implementation experience where SSG limitations caused significant business impact including product rollout disruptions, ad rejections, and marketing campaign inefficiencies, this playbook addresses the critical business drivers, technical challenges, and operational considerations that make this architectural transformation essential for modern digital commerce.Your marketing team launches a campaign at 9 AM. By 9:15, they discover the featured product shows yesterday’s price because the site rebuild hasn’t completed. By 10 AM, Google Ads has rejected the campaign for price mismatch. This scenario—and dozens like it—drove our migration from SSG to SSR. The lessons learned section documents our missteps—including a mid-project pivot from App Router to Pages Router—that shaped the final approach.

  • Next

    Frontend Data Fetching Patterns and Caching

    Frontend Engineering / Frontend Architecture & Patterns 17 min read

    Patterns for fetching, caching, and synchronizing server state in frontend applications. Covers request deduplication, cache invalidation strategies, stale-while-revalidate mechanics, and library implementations.