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:#e8f8e8

Four 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.
#

← Previous: Microservices   Next: Broken Pipe

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact