Micro Frontend #
Micro frontends extend the microservices principle to the frontend side — breaking monolithic web applications into smaller, independent, separately deployable parts owned by different teams. Instead of one large React/Vue/Angular codebase maintained by the entire frontend team, each team owns and deploys their own UI pieces.
The main motivation is the same as microservices in the backend: when organizations grow and team counts increase, coordinating changes and deployments of one large codebase becomes a bottleneck. The Checkout team can’t release their features without waiting for deployment turns alongside the Product, Search, and Account teams.
But like microservices, micro frontends aren’t a solution to every problem. They introduce significant complexity — duplicated dependencies, UI inconsistencies, harder debugging, and different coordination overheads. Understanding these trade-offs before choosing this architecture is key.
Motivations: When Micro Frontends Are Relevant #
Problems micro frontends solve:
Problem 1: Deployment coupling
Team A wants to release a new feature but must wait for Team B to finish
because everything lives in one codebase deployed together.
→ Micro frontends: every team deploys anytime independently
Problem 2: Technology lock-in
The entire frontend must use the same framework
New teams can't try technology better suited to their use cases.
→ Micro frontends: each team can choose the right stack (within reason)
Problem 3: Codebases too large for one team
One PR needs review from many teams unfamiliar with that area
Build times keep growing
→ Micro frontends: each team has a smaller, more manageable codebase
But remember: micro frontends are a solution to ORGANIZATIONAL problems, not technical ones.
If teams are still small, one well-structured codebase is far simpler.
graph TB
subgraph Shell["Shell Application (Host)"]
Nav["Navigation MFE\nPlatform Team"]
Content[Content Area]
end
subgraph MFEs["Micro Frontends (Remotes)"]
Product["Product MFE\nCatalog Team"]
Cart["Cart MFE\nCommerce Team"]
Account["Account MFE\nUser Team"]
Search["Search MFE\nDiscovery Team"]
end
Nav --> Content
Content --> Product
Content --> Cart
Content --> Account
Content --> Search
style Shell fill:#e8f4f8
style Product fill:#e8f8e8
style Cart fill:#e8f8e8
style Account fill:#e8f8e8
style Search fill:#e8f8e8Four Implementation Approaches #
1. Build-Time Integration (npm Packages) #
Each micro frontend is published as an npm package. The shell installs them all and builds together.
// go.mod in the shell/host application
// require (
// github.com/company/product-mfe v2.1.0
// github.com/company/cart-mfe v1.5.0
// github.com/company/account-mfe v3.0.0
// )
// In the shell application
func App() []Component {
return []Component{
ProductMFE(),
CartMFE(),
}
}
Build-time integration:
✓ Advantages:
→ Technically the simplest
→ Full type safety across MFEs
→ Build optimization (tree shaking, code splitting) can be optimal
→ Easier debugging (everything in one build)
✗ Disadvantages:
→ Not truly independent: the shell must rebuild when MFEs update
→ Not genuinely separate deployments
→ Still coupled on dependency versions
→ Doesn't solve deployment coordination problems
When it fits:
→ Teams wanting code separation benefits without independent deployment needs
→ As a first step before migrating to runtime integration
2. Runtime Integration via iFrames #
MFEs load in separate iframes.
<!-- Shell application -->
<html>
<body>
<nav id="nav-mfe"></nav>
<!-- Product MFE in an iframe -->
<iframe
src="https://product-mfe.company.com/products"
style="width: 100%; border: none;"
sandbox="allow-scripts allow-same-origin"
title="Product Catalog"
></iframe>
<!-- Cart MFE in an iframe -->
<iframe
src="https://cart-mfe.company.com/cart"
style="width: 300px; border: none;"
title="Shopping Cart"
></iframe>
</body>
</html>
// Communication between iframes via postMessage
// Inside the Product MFE iframe
func addToCart(product Product) {
// window.parent.postMessage(...) — browser API
messageBus.Send(Message{Type: "ADD_TO_CART", Product: product}, "https://shell.company.com") // specify the origin for security!
}
// In the shell — listening for messages from iframes
func init() {
// ALWAYS validate the origin before processing
messageBus.OnMessage(func(event MessageEvent) {
if event.Origin != "https://product-mfe.company.com" {
return
}
if event.Data.Type == "ADD_TO_CART" {
updateCartState(event.Data.Product)
// Forward to the Cart MFE
cartIframe.Send(Message{Type: "CART_UPDATED", Product: event.Data.Product}, "https://cart-mfe.company.com")
}
})
}
iFrame integration:
✓ Advantages:
→ Full isolation: CSS can't leak, JS can't conflict
→ Strong security sandboxing
→ Truly independent: each MFE has its own URL and deployment
✗ Disadvantages:
→ Limited UX: scrolls, modals, and tooltips can't escape iframe boundaries
→ Very poor SEO: search engines struggle to index iframe content
→ Cross-MFE communication only via verbose postMessage
→ Performance: every iframe is a new browsing context (a new JS engine)
When it fits:
→ Third-party widgets needing strict isolation (payment forms, chat widgets)
→ When security isolation matters more than UX
→ Not for most consumer-facing applications
3. Module Federation (Webpack 5) #
Module Federation is the most popular approach for runtime integration. JavaScript bundles load dynamically at runtime from different servers.
// webpack.config.js — Product MFE (Remote)
// Equivalent configuration as Go structs
type SharedDep struct {
Singleton bool
RequiredVersion string
}
type MfeConfig struct {
Name string // a unique name for this MFE
Filename string // the manifest file loaded by the shell
Exposes map[string]string // components exposed to the shell and other MFEs
Shared map[string]SharedDep
}
var productMFEConfig = MfeConfig{
Name: "productMFE",
Filename: "remoteEntry.js",
Exposes: map[string]string{
// Components exposed to the shell and other MFEs
"./ProductList": "./src/components/ProductList",
"./ProductDetail": "./src/components/ProductDetail",
},
Shared: map[string]SharedDep{
// Declare shared dependencies
// Prevents React from loading twice (once from the shell, once from the MFE)
"react": {Singleton: true, RequiredVersion: "^18.0.0"}, // only one instance
"react-dom": {Singleton: true, RequiredVersion: "^18.0.0"},
},
}
// webpack.config.js — Shell Application (Host)
type RemoteEntry struct {
Name string
URL string
}
var shellConfig = struct {
Name string
Remotes map[string]RemoteEntry
Shared map[string]SharedDep
}{
Name: "shell",
Remotes: map[string]RemoteEntry{
// The MFEs to load remotely
"productMFE": {Name: "productMFE", URL: "https://product-mfe.company.com/remoteEntry.js"},
"cartMFE": {Name: "cartMFE", URL: "https://cart-mfe.company.com/remoteEntry.js"},
"accountMFE": {Name: "accountMFE", URL: "https://account-mfe.company.com/remoteEntry.js"},
},
Shared: map[string]SharedDep{
"react": {Singleton: true, RequiredVersion: "^18.0.0"},
"react-dom": {Singleton: true, RequiredVersion: "^18.0.0"},
},
}
// App — the Shell using components from remote MFEs
// Lazy load components from remote MFEs
// Downloaded from the product-mfe server when needed
func App() {
// Each remote component loads lazily; a fallback is shown while loading
productList := lazyLoad("productMFE/ProductList", "Loading products...")
cartWidget := lazyLoad("cartMFE/CartWidget", "Loading cart...")
render([]Component{
productList.WithProps(map[string]any{"onAddToCart": handleAddToCart}),
cartWidget,
})
}
Module Federation:
✓ Advantages:
→ True independent deployment
→ Shared dependencies (React not loaded twice)
→ Seamless UX (no iframe boundaries)
→ Automatic lazy loading
✗ Disadvantages:
→ High webpack configuration complexity
→ Dependency versions must be compatible across MFEs
→ Runtime errors if remote MFEs are unavailable
→ Harder debugging
When it fits:
→ Large teams needing true independent deployment
→ Existing React/Vue/Angular applications wanting to split
→ When seamless UX is a priority
4. Web Components #
Web Components are a browser standard enabling custom HTML elements usable in any framework.
// product-mfe/src/ProductWidget.go
type ProductWidget struct {
// Shadow DOM for CSS isolation — represented as a separate render container
shadow ShadowRoot
}
// Observed attributes — like props in React
var observedAttributes = []string{"product-id", "show-price"}
func NewProductWidget() *ProductWidget {
return &ProductWidget{shadow: NewShadowRoot(true)} // open mode
}
// Called when the element enters the DOM
func (w *ProductWidget) ConnectedCallback() {
w.Render(nil)
w.LoadProduct(w.GetAttribute("product-id"))
}
// Called when attributes change
func (w *ProductWidget) AttributeChangedCallback(name, oldValue, newValue string) {
if name == "product-id" && oldValue != newValue {
w.LoadProduct(newValue)
}
}
func (w *ProductWidget) LoadProduct(productID string) {
// fetch is asynchronous; render when the product arrives
product := <-fetchProduct(productID)
w.Render(&product)
}
func (w *ProductWidget) Render(product *Product) {
var inner string
if product != nil {
inner = fmt.Sprintf("<div class=\"product-name\">%s</div>\n<div class=\"product-price\">Rp %d</div>", product.Name, product.Price)
} else {
inner = "<div>Loading...</div>"
}
// CSS isolated inside the shadow DOM
w.shadow.SetInnerHTML(
"<style>" +
":host { display: block; padding: 16px; }" +
".product-name { font-size: 1.2em; font-weight: bold; }" +
".product-price { color: #e00; }" +
"</style>" +
"<div class=\"product-widget\">" + inner + "</div>",
)
}
// Custom events for communication outward
func (w *ProductWidget) DispatchAddToCart(product Product) {
w.DispatchEvent(NewCustomEvent("add-to-cart",
// events can be captured by ancestors
// events cross shadow DOM boundaries
map[string]any{"detail": map[string]any{"product": product}, "bubbles": true, "composed": true},
))
}
// Register as a custom element
func init() {
customElements.Define("product-widget", NewProductWidget)
}
<!-- Usage in the shell or any framework -->
<product-widget
product-id="123"
show-price="true">
</product-widget>
<!-- Event handlers -->
<script>
document.querySelector('product-widget')
.addEventListener('add-to-cart', (event) => {
console.log('Add to cart:', event.detail.product)
})
</script>
Shared State: The Biggest Challenge #
One of the biggest technical challenges in micro frontends is how state is shared between independent MFEs.
// Approach 1: Custom Events (simplest, fits simple events)
// MFE A: publish events
func userLoggedIn(user User) {
eventBus.Dispatch("user:logged-in", Event{User: user})
}
// MFE B: subscribe to events — and the shell's store wiring
func init() {
eventBus.Subscribe("user:logged-in", func(event Event) {
updateUIForUser(event.User)
})
// Approach 2: Shared Stores via a Global Object (careful — easy to abuse)
// In the shell application
mfeStore := createGlobalStore(map[string]any{
"user": nil,
"cart": map[string]any{"items": []any{}},
})
// In MFEs — access the shared store
mfeStore.Subscribe("cart", func(cart Cart) {
updateCartBadge(len(cart.Items))
})
mfeStore.Dispatch(map[string]any{"type": "ADD_TO_CART", "product": product})
}
// Approach 3: URLs as shared state
// State needing sharing can live in URLs
// Product MFEs: navigate to /products/123
// Breadcrumb MFEs: read the URL to display the correct breadcrumb
// Advantages: shareable URLs, browser history, no coupling
// Approach 4: Backends as sources of truth
// Instead of syncing state between MFEs, all read from APIs
// Cart MFEs: POST /api/cart/add → response contains the latest cart
// CartBadge MFEs: GET /api/cart/summary → display item counts
// Coupling happens in the backend, not the frontend
Design Systems: The Consistency Foundation #
Micro frontends built by different teams will produce inconsistent UIs without a shared design system.
// @company/design-system — the npm package shared across all MFEs
// Button
type Button struct {
Variant string // defaults to "primary"
Size string // defaults to "medium"
Children []any
Props map[string]any
}
func NewButton(variant, size string, children []any) Button {
return Button{Variant: variant, Size: size, Children: children, Props: map[string]any{}}
}
func (b Button) ClassName() string {
return fmt.Sprintf("btn btn--%s btn--%s", b.Variant, b.Size)
}
// Heading
type Heading struct {
Level int // defaults to 1
Children []any
}
func NewHeading(level int, children []any) Heading {
return Heading{Level: level, Children: children}
}
func (h Heading) Tag() string {
return fmt.Sprintf("h%d", h.Level)
}
func (h Heading) ClassName() string {
return fmt.Sprintf("heading heading--%d", h.Level)
}
// Design tokens — consistent CSS variables
var tokens = map[string]any{
"colors": map[string]string{
"primary": "#0066cc",
"danger": "#dc2626",
"success": "#16a34a",
},
"spacing": map[string]string{
"xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px",
},
"typography": map[string]any{
"fontFamily": "'Inter', sans-serif",
"fontSize": map[string]string{"sm": "14px", "md": "16px", "lg": "18px"},
},
}
Design systems in micro frontends:
What needs standardization:
→ Typography (font families, sizes, weights)
→ Color palettes and semantic colors (primary, danger, success)
→ Spacing scales (4px, 8px, 16px, 24px, ...)
→ Common UI components (Button, Input, Modal, Toast)
→ Icons (one consistent set)
Distribution methods:
→ npm packages (@company/design-system) — most common
→ CSS variables in global stylesheets loaded by shells
→ Storybook as living documentation
Governance:
→ Who is responsible for the design system?
→ How can MFEs request new components?
→ How do versioning and breaking changes work?
→ A dedicated platform team or team rotation?
Micro Frontends vs Frontend Monoliths: When to Choose What #
A decision framework:
Stay with frontend monoliths if:
✓ Teams are still small (< 5-10 frontend engineers)
✓ No real deployment coupling problems
✓ Products are still in fast exploration and iteration phases
✓ Seamless UX is the top priority
✓ Teams have no distributed frontend experience
Consider micro frontends if:
→ 3+ teams frequently conflict in one frontend codebase
→ Different teams need different release schedules
→ Genuinely different business domains (e-commerce + CMS + admin panels)
→ Independent backend teams with stable APIs already exist
→ Teams have experience or are ready to invest in this complexity
Alternatives before micro frontends:
→ Better code modularization (clear module boundaries)
→ Monorepos with per-domain packages
→ Feature flags for independent feature releases
→ npm packages for shared UI without full micro frontends
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: overly granular MFEs
// Buttons as MFEs, Forms as MFEs, Tables as MFEs
// Every page needs dozens of requests to load all the small MFEs
// ✓ Solution: MFEs aligned with business domains, not UI components
// ✗ Anti-pattern 2: shared mutable state via global objects
var globalState = map[string]any{
"user": currentUser,
"cart": cartItems,
}
// No control, race conditions likely, hard to debug
// ✓ Solution: defined event buses, URLs as state, or backends
// ✗ Anti-pattern 3: every MFE bringing a different React version
// productMFE: React 16, cartMFE: React 17, accountMFE: React 18
// Users download React 3 times, poor performance, React Context can't be shared
// ✓ Solution: Module Federation with singleton dependencies, aligned major versions
// ✗ Anti-pattern 4: MFEs tightly coupled to shell routing
// Cart MFEs directly access window.history, assuming specific paths
// Can't be embedded in different contexts
// ✓ Solution: MFEs stateless about routing, receiving config via props/attributes
// ✗ Anti-pattern 5: no error boundaries between MFEs
// One MFE crashing → the entire page crashes
// ✓ Solution: every MFE wrapped in error boundaries, fallback UI when MFEs fail to load
// In the shell:
func MFEWrapper(name string, children []any) any {
// Loading fallback while the MFE loads, error fallback if it fails
showFallback(fmt.Sprintf("Loading %s...", name))
component, err := loadMFE(name)
if err != nil {
return fmt.Sprintf("Failed to load %s", name)
}
return component(children)
}
Micro Frontend Checklist #
ARCHITECTURE:
□ Each MFE aligns with a clear business domain (not UI components)
□ MFEs deployable independently without coordination
□ Each MFE has a team owning it (clear ownership)
□ MFEs not tightly coupled to shell routing or state
INTEGRATION:
□ Integration methods chosen per needs (iframes/Module Fed/Web Components)
□ Shared dependencies declared as singletons (preventing duplicate Reacts)
□ Error boundaries on every MFE preventing crash spreading
□ Loading states for every asynchronously loaded MFE
STATE MANAGEMENT:
□ Cross-MFE communication contracts clearly defined
□ No uncontrolled shared mutable global state
□ Event contracts documented (event names, payload schemas)
DESIGN SYSTEMS:
□ Design systems available as shared packages
□ All MFEs use the same design system for common components
□ Design tokens (colors, spacing, typography) consistent
PERFORMANCE:
□ Bundle sizes monitored per MFE with budgets
□ Shared dependencies not duplicated (Module Federation shared configs)
□ Lazy loading configured for MFEs not needed at initial load
□ Prefetching for MFEs likely needed next
OPERATIONAL:
□ Each MFE has its own CI/CD pipeline
□ Clear versioning strategies (semver or immutable URLs with content hashes)
□ Rollbacks possible per MFE without redeploying shells
□ Per-MFE monitoring (error rates, load times, availability)
Summary #
- Micro frontends solve organizational problems, not technical ones — if the bottleneck is large-team coordination for shared deployments, micro frontends are relevant. If the problems are technical (performance, code quality), simpler solutions exist.
- Start with a well-structured monolith — like microservices in the backend, well-structured monoliths are easier to manage and can evolve into micro frontends when real needs emerge.
- Module Federation is the most popular runtime integration approach — enabling independent deployment with seamless UX and shared dependencies. But webpack configuration complexity must be factored in.
- iFrames fit widgets needing strict isolation — payment forms, chat widgets — not general consumer-facing applications because of UX and SEO limitations.
- Web Components provide cross-framework interoperability — components written as Web Components work in React, Vue, Angular, or vanilla JS without special bindings.
- Shared dependencies must be declared as singletons — React loaded twice causes hard-to-debug bugs and poor performance. Module Federation’s
singleton: true solves this. - Design systems are the consistency foundation — without shared design systems, UIs become inconsistent across MFEs from different teams. This investment can’t be postponed.
- Error boundaries between MFEs are mandatory — one crashing MFE must not destroy the whole page. Every MFE should be wrapped in error boundaries with fallback UIs.
- Cross-MFE state management needs clear contracts — custom events, URLs as state, or backends as single sources of truth are better than shared mutable global objects.
- Operational complexity increases significantly — more deployment units, more to monitor, more complex dependency matrices. Make sure teams are ready before choosing this architecture.
#
- Micro frontends solve organizational problems, not technical ones — if the bottleneck is large-team coordination for shared deployments, micro frontends are relevant. If the problems are technical (performance, code quality), simpler solutions exist.
- Start with a well-structured monolith — like microservices in the backend, well-structured monoliths are easier to manage and can evolve into micro frontends when real needs emerge.
- Module Federation is the most popular runtime integration approach — enabling independent deployment with seamless UX and shared dependencies. But webpack configuration complexity must be factored in.
- iFrames fit widgets needing strict isolation — payment forms, chat widgets — not general consumer-facing applications because of UX and SEO limitations.
- Web Components provide cross-framework interoperability — components written as Web Components work in React, Vue, Angular, or vanilla JS without special bindings.
- Shared dependencies must be declared as singletons — React loaded twice causes hard-to-debug bugs and poor performance. Module Federation’s
singleton: truesolves this. - Design systems are the consistency foundation — without shared design systems, UIs become inconsistent across MFEs from different teams. This investment can’t be postponed.
- Error boundaries between MFEs are mandatory — one crashing MFE must not destroy the whole page. Every MFE should be wrapped in error boundaries with fallback UIs.
- Cross-MFE state management needs clear contracts — custom events, URLs as state, or backends as single sources of truth are better than shared mutable global objects.
- Operational complexity increases significantly — more deployment units, more to monitor, more complex dependency matrices. Make sure teams are ready before choosing this architecture.