Back to blog
Advertisement

Microfrontends: The Architecture Big Companies Use to Scale Teams and Applications

Hello HaWkers, imagine having 50 developers working on the same frontend application without one team blocking another. Sounds impossible? Companies like Spotify, IKEA, Amazon, and Zalando do this every day using microfrontends - and this architecture is becoming increasingly relevant in 2025.

The problem that microfrontends solve is very real: as applications and teams grow, frontend monoliths become bottlenecks. Coordinated deployments between teams, endless merge conflicts, legacy technologies blocking innovation. Microfrontends bring to the frontend the same benefits that microservices brought to the backend.

What Are Microfrontends?

Microfrontends is an architecture that divides a frontend application into smaller, independent pieces, each of which can be developed, tested, and deployed by different teams using potentially different technologies.

Think of an e-commerce: the catalog team handles product listings, the checkout team handles cart and payment, the account team handles user profile. Each team works on its own "micro-app", but to the end user everything appears as a single, integrated application.

The big insight is autonomy. Each team can choose their stack (React, Vue, Angular), define their own release cycle, and make independent deployments without coordinating with 10 other teams.

// Conceptual example: Project structure with microfrontends
/*
my-ecommerce/
β”œβ”€β”€ container/              // Shell application that orchestrates everything
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ App.jsx
β”‚   β”‚   └── bootstrap.jsx
β”‚   β”œβ”€β”€ webpack.config.js   // Module Federation config
β”‚   └── package.json
β”‚
β”œβ”€β”€ products/               // Products microfrontend (team A)
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ ProductList.jsx
β”‚   β”‚   β”œβ”€β”€ ProductDetail.jsx
β”‚   β”‚   └── index.js       // Exports to expose
β”‚   β”œβ”€β”€ webpack.config.js
β”‚   └── package.json       // React 18 + their libs
β”‚
β”œβ”€β”€ cart/                   // Cart microfrontend (team B)
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ Cart.jsx
β”‚   β”‚   β”œβ”€β”€ Checkout.jsx
β”‚   β”‚   └── index.js
β”‚   β”œβ”€β”€ webpack.config.js
β”‚   └── package.json       // Can even use Vue if they want!
β”‚
└── account/                // Account microfrontend (team C)
    β”œβ”€β”€ src/
    β”‚   β”œβ”€β”€ Profile.jsx
    β”‚   β”œβ”€β”€ Orders.jsx
    β”‚   └── index.js
    β”œβ”€β”€ webpack.config.js
    └── package.json       // React 17 + specific libs
*/

// container/webpack.config.js - Orchestrator
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        // Load microfrontends remotely
        products: 'products@http://localhost:3001/remoteEntry.js',
        cart: 'cart@http://localhost:3002/remoteEntry.js',
        account: 'account@http://localhost:3003/remoteEntry.js'
      },
      shared: {
        // Share dependencies to avoid duplication
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
      }
    })
  ]
};

// container/src/App.jsx - Microfrontend composition
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Lazy load microfrontends
const ProductList = lazy(() => import('products/ProductList'));
const ProductDetail = lazy(() => import('products/ProductDetail'));
const Cart = lazy(() => import('cart/Cart'));
const Checkout = lazy(() => import('cart/Checkout'));
const Profile = lazy(() => import('account/Profile'));
const Orders = lazy(() => import('account/Orders'));

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <Header /> {/* Container component */}

        <Suspense fallback={<div>Loading...</div>}>
          <Routes>
            {/* Routes managed by products */}
            <Route path="/products" element={<ProductList />} />
            <Route path="/products/:id" element={<ProductDetail />} />

            {/* Routes managed by cart */}
            <Route path="/cart" element={<Cart />} />
            <Route path="/checkout" element={<Checkout />} />

            {/* Routes managed by account */}
            <Route path="/profile" element={<Profile />} />
            <Route path="/orders" element={<Orders />} />
          </Routes>
        </Suspense>

        <Footer /> {/* Container component */}
      </div>
    </BrowserRouter>
  );
}

export default App;

Each microfrontend runs on its own development server and can be deployed independently. The container orchestrates them at runtime.

Advertisement

Module Federation: The Technology That Made Microfrontends Practical

Module Federation, introduced in Webpack 5, revolutionized microfrontends by enabling dynamic sharing of JavaScript code between completely separate applications.

Before Module Federation, implementing microfrontends was complex and full of trade-offs. Now it is surprisingly elegant.

// products/webpack.config.js - Microfrontend that exposes components
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devServer: {
    port: 3001,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  },

  plugins: [
    new ModuleFederationPlugin({
      name: 'products',
      filename: 'remoteEntry.js',

      // What this microfrontend exposes
      exposes: {
        './ProductList': './src/components/ProductList',
        './ProductDetail': './src/components/ProductDetail',
        './ProductCard': './src/components/ProductCard'
      },

      // What it can consume from others
      remotes: {
        cart: 'cart@http://localhost:3002/remoteEntry.js'
      },

      // Shared dependencies
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
          eager: false
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
          eager: false
        }
      }
    })
  ]
};

// products/src/components/ProductList.jsx
import React, { useState, useEffect } from 'react';
// Can import component from ANOTHER microfrontend!
import { AddToCartButton } from 'cart/AddToCartButton';

export default function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(setProducts);
  }, []);

  return (
    <div className="product-grid">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p className="price">${product.price}</p>

          {/* Using component from "cart" microfrontend */}
          <AddToCartButton productId={product.id} />
        </div>
      ))}
    </div>
  );
}

The magic is in how Module Federation manages shared dependencies. If products and cart use React 18, only one copy is loaded and shared between them.

microfrontends architecture

Advertisement

Communication Patterns Between Microfrontends

The challenge in microfrontends is communication. How does the cart microfrontend know when a product was added? How to synchronize state between independent apps?

There are several patterns:

1. Custom Events (Browser API)

// products/ProductCard.jsx - Dispatches event
function addToCart(product) {
  // Local logic
  const event = new CustomEvent('product:added-to-cart', {
    detail: { product }
  });
  window.dispatchEvent(event);
}

// cart/CartIcon.jsx - Listens to event
import { useEffect, useState } from 'react';

function CartIcon() {
  const [itemCount, setItemCount] = useState(0);

  useEffect(() => {
    function handleProductAdded(event) {
      console.log('Product added:', event.detail.product);
      setItemCount(prev => prev + 1);
    }

    window.addEventListener('product:added-to-cart', handleProductAdded);

    return () => {
      window.removeEventListener('product:added-to-cart', handleProductAdded);
    };
  }, []);

  return (
    <div className="cart-icon">
      πŸ›’ {itemCount > 0 && <span className="badge">{itemCount}</span>}
    </div>
  );
}

2. Shared State Store (Redux, Zustand, etc)

// shared/store.js - Store shared by all
import create from 'zustand';

export const useCartStore = create((set) => ({
  items: [],
  addItem: (product) => set((state) => ({
    items: [...state.items, product]
  })),
  removeItem: (productId) => set((state) => ({
    items: state.items.filter(item => item.id !== productId)
  })),
  getTotal: () => {
    // Calculation logic
  }
}));

// Exposed by container and shared
// container/webpack.config.js
new ModuleFederationPlugin({
  name: 'container',
  exposes: {
    './store': './src/shared/store'
  },
  // ...
});

// products/ProductCard.jsx - Uses shared store
import { useCartStore } from 'container/store';

function ProductCard({ product }) {
  const addItem = useCartStore(state => state.addItem);

  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => addItem(product)}>
        Add to Cart
      </button>
    </div>
  );
}

// cart/Cart.jsx - Also uses the same store
import { useCartStore } from 'container/store';

function Cart() {
  const items = useCartStore(state => state.items);
  const removeItem = useCartStore(state => state.removeItem);

  return (
    <div>
      <h2>Cart ({items.length})</h2>
      {items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
    </div>
  );
}

3. Props Drilling (Container passes props)

// container/App.jsx - Container manages state and passes props
import React, { useState } from 'react';
import ProductList from 'products/ProductList';
import CartIcon from 'cart/CartIcon';

function App() {
  const [cartItems, setCartItems] = useState([]);

  const handleAddToCart = (product) => {
    setCartItems(prev => [...prev, product]);
  };

  const handleRemoveFromCart = (productId) => {
    setCartItems(prev => prev.filter(item => item.id !== productId));
  };

  return (
    <div>
      <header>
        <CartIcon
          items={cartItems}
          onRemove={handleRemoveFromCart}
        />
      </header>

      <ProductList onAddToCart={handleAddToCart} />
    </div>
  );
}
Advertisement

Challenges and Trade-offs of Microfrontends

Microfrontends are not a magic solution. They bring complexity:

1. Dependency Duplication Even with shared modules, different library versions can be loaded, increasing the total bundle.

2. Deployment Complexity Now you have multiple deployments to coordinate. Versioning and compatibility between microfrontends requires discipline.

3. End-to-End Tests Testing the complete integration is more complex when parts run in different repositories and pipelines.

4. Performance Additional network requests to load remotes can impact initial loading time.

// Strategy to minimize performance issues
// container/webpack.config.js
new ModuleFederationPlugin({
  name: 'container',
  remotes: {
    // In production, use CDN with specific version
    products: process.env.NODE_ENV === 'production'
      ? 'products@https://cdn.example.com/products/v1.2.3/remoteEntry.js'
      : 'products@http://localhost:3001/remoteEntry.js',
  },
  shared: {
    react: {
      singleton: true,
      eager: true,  // Load immediately in container
      requiredVersion: '^18.0.0'
    }
  }
});

// Prefetch strategy to improve perceived performance
// container/src/App.jsx
import { useEffect } from 'react';

function App() {
  useEffect(() => {
    // Prefetch microfrontends that will likely be used
    import('products/ProductList');
    import('cart/CartIcon');
  }, []);

  return (
    // ...
  );
}

When to Use (And When NOT to Use) Microfrontends

Use microfrontends when:

  • You have multiple teams working on the same product
  • Parts of the application evolve at very different paces
  • You need to gradually migrate from one technology to another
  • Different parts have very different scale requirements

DO NOT use microfrontends when:

  • Your team has less than 10 developers
  • Your application is relatively simple
  • You do not have robust CI/CD infrastructure
  • Performance is critical and you cannot accept overhead

For most small and medium projects, a well-structured monolith is simpler and more effective.

The Future of Microfrontends

The trend is strong. Tools like Single-SPA, Bit, and now Webpack Module Federation have made microfrontends accessible. In 2025, we see growth in adoption, especially in medium-large companies.

Frameworks are adapting. There are emerging solutions for Vite (vite-plugin-federation) and discussions about native support in Next.js and other meta-frameworks.

The architecture will continue to evolve, but the core concept of team autonomy and independent deployments is here to stay.

If you are intrigued by architectures that enable scale, I recommend checking out another article: React Server Components: The New Era of Full-Stack Development where you will discover another revolutionary approach to structuring modern applications.

Let's go! πŸ¦…

🎯 Join Developers Who Are Evolving

Thousands of developers already use our material to accelerate their studies and achieve better positions in the market.

Why invest in structured knowledge?

Learning in an organized way with practical examples makes all the difference in your journey as a developer.

Start now:

  • 2x of $13.08 on card
  • or $24.90 at sight

πŸš€ Access Complete Guide

"Excellent material for those who want to go deeper!" - John, Developer

Advertisement
Previous postNext post

Comments (0)

This article has no comments yet 😒. Be the first! πŸš€πŸ¦…

Add comments