MFE Communication Patterns

Micro Frontend Communication Patterns

The biggest challenge after splitting a monolith into Micro Frontends is not the split itself — it is how those independent applications communicate with each other. When the Products MFE adds an item to the cart, how does the Cart MFE know? When the Auth MFE logs the user out, how does every other MFE clear user-specific data?

In this article, you will learn the five main Micro Frontend communication patterns used in production applications, when to use each one, their trade-offs, and real code examples based on how MFE architectures are actually built with Next.js (opens in a new tab), Webpack Module Federation (opens in a new tab), and Redux Toolkit (opens in a new tab).

New to Micro Frontend? Start with What is Micro Frontend Architecture? first. Already familiar? Make sure you've read Micro Frontend vs Monolith for the architectural context.

The Communication Problem

In a Monolith, communication is effortless — every component shares the same Redux store, the same context providers, and the same function scope. A button click in ProductCard can dispatch an action that updates CartBadge instantly because they live in the same application.

In Micro Frontend architecture, each MFE is a separate application with its own:

  • Bundled JavaScript (separate Webpack builds)
  • Component tree (separate React root)
  • Internal state (separate local state)

So how does ProductCard in the Products MFE tell CartBadge in the Header that the cart count changed?

There are five patterns used in production MFE applications:

Micro Frontend communication patterns overview showing five approaches — Shared Redux Store, Callback Props, URL Routing, Shared API Layer, and SessionStorage — with arrows indicating data flow between Host and Remote MFEs

Pattern 1: Shared Redux Store (Primary Pattern)

This is the most common pattern in production MFE applications. All MFEs share a single Redux store instance through a shared npm workspace package, loaded as a singleton via Module Federation.

How It Works

  1. Create a Redux store as a shared npm workspace package (e.g., @myapp/store)
  2. Define slices for global state — userSlice, cartSlice, etc.
  3. Every MFE wraps its root with the shared StoreProvider
  4. Module Federation's singleton: true ensures only one store instance exists at runtime
  5. When any MFE dispatches an action, all MFEs re-render automatically

Code Example

packages/store/src/cartSlice.js
// packages/store/src/cartSlice.js
// Shared npm workspace package — used by ALL MFEs
import { createSlice } from '@reduxjs/toolkit'

const calculateTotals = (items) => ({
  totalItems: items.reduce((sum, i) => sum + i.quantity, 0),
  totalMRP: items.reduce((sum, i) => sum + i.mrp * i.quantity, 0),
  totalDiscount: items.reduce((sum, i) => sum + (i.mrp - i.price) * i.quantity, 0),
  totalAmount: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
})

const cartSlice = createSlice({
  name: 'cart',
  initialState: {
    items: [],
    selectedItems: [],
    totalItems: 0,
    totalMRP: 0,
    totalDiscount: 0,
    totalAmount: 0,
  },
  reducers: {
    addToCart: (state, action) => {
      const existing = state.items.find(i => i.id === action.payload.id)
      if (existing) {
        existing.quantity += 1
      } else {
        state.items.push({ ...action.payload, quantity: 1 })
      }
      Object.assign(state, calculateTotals(state.items))
    },
    removeFromCart: (state, action) => {
      state.items = state.items.filter(i => i.id !== action.payload)
      Object.assign(state, calculateTotals(state.items))
    },
    updateQuantity: (state, action) => {
      const item = state.items.find(i => i.id === action.payload.id)
      if (item) item.quantity = action.payload.quantity
      Object.assign(state, calculateTotals(state.items))
    },
    clearCart: (state) => {
      state.items = []
      state.selectedItems = []
      Object.assign(state, calculateTotals([]))
    },
  },
})

export const { addToCart, removeFromCart, updateQuantity, clearCart } = cartSlice.actions
export default cartSlice.reducer

Module Federation Config — Singleton Is Critical

Both the Host and every Remote MFE must declare the shared store as singleton: true. Without this, Module Federation loads separate instances — each MFE has its own state and they cannot see each other's changes.

apps/host/next.config.js
// apps/host/next.config.js
const { NextFederationPlugin } = require('@module-federation/nextjs-mf')

module.exports = {
  webpack(config, options) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'Host',
        filename: 'static/chunks/remoteEntry.js',
        remotes: {
          Products: 'Products@/products/_next/static/chunks/remoteEntry.js',
          Cart:     'Cart@/cart/remoteEntry.js',
          Auth:     'Auth@/auth/remoteEntry.js',
          Account:  'Account@/account/remoteEntry.js',
          Support:  'Support@/support/remoteEntry.js',
        },
        shared: {
          react:              { singleton: true, requiredVersion: false },
          'react-dom':        { singleton: true, requiredVersion: false },
          'react-redux':      { singleton: true, requiredVersion: false },
          '@reduxjs/toolkit':  { singleton: true, requiredVersion: false },
          '@myapp/store':     { singleton: true, strictVersion: true, requiredVersion: '1.0.0' },
          '@myapp/api':       { singleton: true, strictVersion: true, requiredVersion: '1.0.0' },
        },
      })
    )
    return config
  },
}

Why NextFederationPlugin for the Host? If your Host MFE uses Next.js (for SSR and SEO), use NextFederationPlugin from @module-federation/nextjs-mf instead of the standard ModuleFederationPlugin. It handles Next.js-specific concerns like SSR chunks, page routing, and static asset loading. Remote MFEs that are plain React apps (not Next.js) still use the standard ModuleFederationPlugin.

⚠️

Version mismatch breaks singletons. If the Host has @reduxjs/toolkit@2.0.0 but Cart MFE has @reduxjs/toolkit@1.9.0, Module Federation may load two separate instances — breaking shared state. Always pin the same version across all MFEs. Use requiredVersion: '1.0.0' with strictVersion: true for your shared packages.

When to Use Shared State

Use WhenAvoid When
Multiple MFEs read/write the same data (cart, auth)Data is local to one MFE only
State must stay in sync in real-time across MFEsSimple one-way notifications
Complex state with reducers and async thunksYou want MFEs to be completely independent

Pattern 2: Callback Props (Host → Remote)

In a Next.js MFE architecture, the Host owns the router (next/router). Remote MFEs cannot import the Host's router directly — that would create tight coupling. Instead, the Host passes callback functions as props when loading Remote components.

How It Works

  1. Host loads Remote MFE using next/dynamic with ssr: false
  2. Host passes callbacks: handleNavigate, onGoToPayment, onSelectedItemsChange
  3. Remote MFE calls these callbacks when it needs the Host to do something (navigate, show toast, etc.)
  4. Remote MFE stays independent — it doesn't know or care how navigation works

Code Example

apps/host/pages/bag/index.tsx
// apps/host/pages/bag/index.tsx
// Host loads Remote MFE using next/dynamic (NOT React.lazy)
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'

const RemoteShoppingBag = dynamic(
  () => import('Cart/ShoppingBag'),
  { ssr: false, loading: () => <p>Loading cart...</p> }
)

export default function BagPage() {
  const router = useRouter()

  const handleGoToPayment = () => {
    router.push('/checkout/payment')
  }

  const handleNavigate = (path) => {
    router.push(path)
  }

  return (
    <RemoteShoppingBag
      onGoToPayment={handleGoToPayment}
      handleNavigate={handleNavigate}
    />
  )
}

Why next/dynamic instead of React.lazy? In a Next.js application, React.lazy does not support SSR. next/dynamic with ssr: false ensures the Remote MFE loads only on the client side — preventing hydration errors and server-side import failures for Module Federation remotes.

Common Callback Props in MFE Applications

Prop NamePurpose
handleNavigate(path)Navigate to any route via Host router
onGoToPayment()Navigate to checkout/payment flow
onSelectedItemsChange(items)Notify Host when selection changes
onShowToast(message)Trigger toast notification from Host
productNumericIdPass route params to Remote component

When to Use Callback Props

Use WhenAvoid When
Remote MFE needs Host to navigateData that changes frequently (causes re-renders)
One-way communication (Remote → Host action)MFE-to-MFE communication (without Host)
Passing route parameters to Remote componentsLarge or complex data objects

Pattern 3: URL / Route-Based Communication

The URL is a global, shared state that every MFE can read. In a Next.js MFE architecture, the Host application uses file-based routing — each page file dynamically imports the corresponding Remote MFE.

How It Works

  1. Host has pages like pages/product/[id]/index.tsx, pages/bag/index.tsx
  2. Next.js router resolves the URL and renders the correct page
  3. The page loads the corresponding Remote MFE via next/dynamic
  4. Route params ([id]) are passed as props to the Remote component
  5. MFEs can "communicate" by navigating — Cart navigates to /checkout/payment, which loads the Payment MFE

When to Use URL Routing

Use WhenAvoid When
Page-level MFE loading (one MFE per route)Component-level composition (multiple MFEs on one page)
Deep linking and bookmark support neededData that shouldn't be visible in the URL
Navigation between domains (Products → Cart → Checkout)Sensitive data (auth tokens, personal info)
SEO matters (URLs are crawlable by Google)High-frequency state changes

Pattern 4: Shared API Layer

Just like the shared Redux store, a shared API layer is packaged as a singleton npm workspace package. All MFEs use the same axios (opens in a new tab) instance with centralized interceptors for authentication, error handling, and token refresh.

How It Works

  1. Create a shared axios instance as @myapp/api package
  2. Request interceptor reads the access token from the shared Redux store and attaches it as a Bearer token
  3. Response interceptor handles 401 errors — automatically refreshes the token and retries the failed request
  4. Every MFE imports api from the shared package — no MFE manages auth tokens directly

Code Example

packages/api/src/index.js
// packages/api/src/index.js
// Shared API layer — singleton across all MFEs
import axios from 'axios'
import { selectAccessToken } from '@myapp/store/userSlice'
import { store } from '@myapp/store'

const api = axios.create({
  baseURL: 'https://api.example.com',
  headers: { 'Content-Type': 'application/json' },
  withCredentials: true,
})

// Request interceptor — auto-adds auth token from Redux store
api.interceptors.request.use((config) => {
  const state = store.getState()
  const token = selectAccessToken(state)
  if (token) {
    config.headers.Authorization = 'Bearer ' + token
  }
  return config
})

// Response interceptor — handles token refresh on 401
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Attempt token refresh, retry failed request
      const newToken = await refreshToken()
      error.config.headers.Authorization = 'Bearer ' + newToken
      return api.request(error.config)
    }
    return Promise.reject(error)
  }
)

export default api

Why This Matters

Without a shared API layer, every MFE would need to:

  • Read the auth token from the store independently
  • Implement its own token refresh logic
  • Handle 401 errors and session expiry separately

This leads to duplicated code and inconsistent error handling. A shared API package solves this once — all MFEs benefit automatically.

When to Use Shared API

Use WhenAvoid When
All MFEs call the same backend APIMFEs call completely different backends
Authentication is centralized (shared token)Each MFE has its own auth mechanism
You want consistent error handlingAPI logic is trivial (one or two endpoints)

Pattern 5: SessionStorage (Cross-Page Data)

When an MFE needs to pass data to another MFE across a page navigation — and that data is too complex or sensitive for URL params — sessionStorage is a practical approach. The data lives only for the browser session and is automatically cleared when the tab closes.

Code Example

apps/cart/src/ShoppingBag.jsx
// Cart MFE — saves selected items before navigating to payment
const handleProceedToPayment = () => {
  // Save data needed by the next page
  sessionStorage.setItem(
    'selectedCartItems',
    JSON.stringify(selectedItems)
  )
  // Use Host callback to navigate
  onGoToPayment()
}

// Payment MFE — reads the data on mount
useEffect(() => {
  const saved = sessionStorage.getItem('selectedCartItems')
  if (saved) {
    const items = JSON.parse(saved)
    setPaymentItems(items)
    // Clean up after reading
    sessionStorage.removeItem('selectedCartItems')
  }
}, [])

When to Use SessionStorage

Use WhenAvoid When
Passing complex data between page navigationsData that needs to persist across sessions
Data too large or sensitive for URL paramsReal-time state sync between MFEs
One-time data transfer (read once, delete)Frequent read/write across components

Bonus: Custom Events (Browser Native)

For simple, fire-and-forget notifications where you want zero coupling between MFEs, browser Custom Events (opens in a new tab) work well. However, in most production MFE applications, the shared Redux store handles this more reliably.

Custom Events example
// Custom Events — useful for fire-and-forget notifications
// Cart MFE — notify other MFEs when cart changes
window.dispatchEvent(
  new CustomEvent('cart:item-added', {
    detail: { productId: 123, cartCount: 5 }
  })
)

// Header MFE — listen for cart updates
useEffect(() => {
  const handler = (e) => setBadgeCount(e.detail.cartCount)
  window.addEventListener('cart:item-added', handler)
  return () => window.removeEventListener('cart:item-added', handler)
}, [])

When to use Custom Events over Shared Redux: Use Custom Events when the sending MFE and receiving MFE have no shared state dependency — for example, an analytics MFE that just listens for page events without needing access to the Redux store.

Anti-Pattern: Direct Imports Between MFEs

The one thing you must never do — import internal files from another MFE directly.

Micro Frontend anti-pattern diagram showing wrong approach with direct imports creating tight coupling versus correct approach using shared packages

apps/products/src/ProductCard.jsx
// WRONG — Products MFE imports Cart MFE internal files
import { cartState } from 'Cart/src/store/cartSlice'

// This creates tight coupling between MFEs
// If Cart team renames or moves this file, Products breaks
// Both MFEs must deploy together — defeating the purpose of MFE

Rule of thumb: If deploying one MFE requires changes in another MFE, they are too tightly coupled. Each MFE must be independently deployable.

Which Pattern Should You Use?

Micro Frontend communication decision tree showing how to choose between Shared Store, Callback Props, URL Routing, Shared API, and SessionStorage based on data flow requirements

ScenarioRecommended Pattern
Cart state shared across Products, Header, CheckoutShared Redux Store
Remote MFE needs to navigate to another pageCallback Props (handleNavigate)
Loading different MFEs based on URL pathURL Routing (Next.js pages)
All MFEs need authenticated API callsShared API Layer
Passing order details from Cart to Payment pageSessionStorage
Analytics MFE listening for page eventsCustom Events
Auth logout → clear all user data everywhereShared Redux (dispatch(clearUser()))

The Golden Rule

Share as little as possible. Each MFE should own its local state. Only put state in the shared store that genuinely needs to be global — authenticated user, cart items, global settings. Everything else stays local to the MFE that owns it.

Summary

PatternCouplingBest ForReal Example
Shared Redux StoreMediumGlobal state (auth, cart)Products dispatches addToCart → Cart re-renders
Callback PropsLowHost → Remote actionsHost passes handleNavigate to Cart MFE
URL RoutingLowestPage navigation/product/[id] loads Products MFE
Shared API LayerMediumCentralized HTTP + authAll MFEs use same axios with auto-token
SessionStorageLowestCross-page data transferCart saves items → Payment reads on mount
Custom EventsLowestFire-and-forgetAnalytics listens for page view events

Production MFE applications use a combination — Shared Redux for auth and cart state, Callback Props for navigation, URL routing for page-level MFE loading, a shared API layer for all HTTP calls, and SessionStorage for cross-page data when needed.

What's Next?

Now that you understand how Micro Frontends communicate, the next step is learning how to deploy them — CI/CD pipelines, Nginx configuration, and independent deployment strategies.

← Back to 5 MFE Integration Patterns

Continue to Micro Frontend Deployment Strategies →


Frequently Asked Questions

How do Micro Frontends communicate with each other?

Micro Frontends communicate using five main patterns: Shared Redux Store (primary — all MFEs share one store instance via Module Federation singleton), Callback Props (Host passes navigation and action callbacks to Remote MFEs), URL/Route-based communication (Next.js dynamic routes with query params), Shared API Layer (centralized axios instance with auth interceptors), and SessionStorage for cross-page data passing.

Should Micro Frontends share a Redux store?

Yes, for data that genuinely needs to be global — like authenticated user state and cart items. The recommended approach is to create a shared store as an npm workspace package and load it as a singleton via Module Federation. All MFEs import from the same package and share one store instance at runtime. Keep MFE-specific state local.

How does Module Federation singleton work for shared state?

When you mark a shared package as singleton: true in Module Federation config, it ensures only one instance of that package is loaded at runtime — even though multiple MFEs bundle it. This is critical for Redux because if two store instances exist, MFEs cannot see each other's state changes. Always pin the same version across all MFEs.

How do you load Remote MFE components in a Next.js Host?

Use next/dynamic with ssr: false — not React.lazy. Example: const RemoteCart = dynamic(() => import('Cart/ShoppingBag'), { ssr: false }). The Host passes callback props like handleNavigate and onGoToPayment so the Remote MFE can trigger navigation without directly importing the Host's router.

How do you avoid tight coupling between Micro Frontends?

Never import internal files from another MFE directly. Instead, communicate through shared packages — a shared store for state, a shared API layer for HTTP calls, and callback props for navigation. If deploying one MFE requires changes in another, they are too tightly coupled. Each MFE should be independently deployable.