Skip to content
All essays
CraftMarch 27, 202415 min

Micro-Frontends Architecture Guide

Scale frontend development with micro-frontends architecture for large teams and complex applications.

Ü
Ümit Uz
Mobile & Full Stack Developer

Micro-frontends extend the microservices concept to frontend development, enabling teams to work independently on different parts of the same application. Here's how to implement this architecture effectively.

What Are Micro-Frontends?

Micro-frontends break down monolithic frontend applications into smaller, semi-independent applications that different teams can develop, test, and deploy separately.

javascript
// Traditional monolithic app
<app>
  <header />
  <sidebar />
  <dashboard />
  <reports />
  <settings />
</app>

// Micro-frontends approach
<app>
  <HeaderMicroFrontend />
  <SidebarMicroFrontend />
  <DashboardMicroFrontend />
  <ReportsMicroFrontend />
  <SettingsMicroFrontend />
</app>

Core Principles

Team Autonomy

Each team owns their micro-frontend end-to-end.

javascript
// Team Checkout owns checkout module
// Team Dashboard owns dashboard module
// Team Search owns search module

// Independent deployment
checkout: https://checkout.example.com
dashboard: https://dashboard.example.com
search: https://search.example.com

Technology Agnostic

Different teams can use different frameworks.

javascript
// Team A: React
<CheckoutReactApp />

// Team B: Vue
<DashboardVueApp />

// Team C: Angular
<SearchAngularApp />

Independent Deployment

Deploy without coordinating with other teams.

yaml
# Team Checkout CI/CD
deploy-checkout:
  script:
    - npm run build
    - npm run test
    - npm run deploy:checkout
  only:
    changes:
      - packages/checkout/**/*

Integration Patterns

Build-Time Integration

Combine during build process.

javascript
// webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        checkout: 'checkout@https://checkout.example.com/remoteEntry.js',
        dashboard: 'dashboard@https://dashboard.example.com/remoteEntry.js',
      },
    }),
  ],
};

Run-Time Integration

Load dynamically at runtime.

javascript
// Dynamic import
const loadMicroFrontend = async (name, url) => {
  const module = await System.import(url);
  return module.default;
};

// Usage in React
function MicroFrontend({ name, url }) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    loadMicroFrontend(name, url).then(setComponent);
  }, [name, url]);

  return Component ? <Component /> : <Loading />;
}

Server-Side Integration

Compose on the server before sending to client.

javascript
// Next.js example
export async function getServerSideProps() {
  const [checkout, dashboard] = await Promise.all([
    fetch('https://checkout.example.com/api/ssr').then(r => r.text()),
    fetch('https://dashboard.example.com/api/ssr').then(r => r.text()),
  ]);

  return {
    props: {
      checkoutHtml: checkout,
      dashboardHtml: dashboard,
    },
  };
}

Module Federation

Webpack Module Federation makes micro-frontends easier.

javascript
// Host app (container)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'container',
      remotes: {
        products: 'products@https://products.example.com/remoteEntry.js',
        cart: 'cart@https://cart.example.com/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

// Remote app (products)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'products',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductsApp': './src/App',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
      },
    }),
  ],
};

Using Remote Modules

javascript
// In host app
import ProductsApp from 'products/ProductsApp';
import CartApp from 'cart/CartApp';

function App() {
  return (
    <div>
      <ProductsApp />
      <CartApp />
    </div>
  );
}

Communication Patterns

Custom Events

Loose coupling between micro-frontends.

javascript
// Publisher (Checkout micro-frontend)
window.dispatchEvent(new CustomEvent('cart-updated', {
  detail: { itemCount: 5, total: 99.99 }
}));

// Subscriber (Header micro-frontend)
useEffect(() => {
  const handleCartUpdate = (event) => {
    const { itemCount, total } = event.detail;
    updateCartBadge(itemCount, total);
  };

  window.addEventListener('cart-updated', handleCartUpdate);
  return () => {
    window.removeEventListener('cart-updated', handleCartUpdate);
  };
}, []);

Shared State Library

Use a global state manager.

javascript
// Using Redux with context
import { Provider } from 'react-redux';
import store from './store';

function MicroFrontendRoot({ children }) {
  return (
    <Provider store={store}>
      {children}
    </Provider>
  );
}

// Shared state slices
export const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [], total: 0 },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);
      state.total += action.payload.price;
    },
  },
});

Message Bus

Event-driven communication.

javascript
// EventBus implementation
class EventBus {
  constructor() {
    this.events = {};
  }

  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }

  publish(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data));
    }
  }
}

const eventBus = new EventBus();

// Usage in micro-frontends
eventBus.subscribe('user:login', (user) => {
  updateUI(user);
});

eventBus.publish('user:login', userData);

Routing Strategies

Client-Side Routing

Each micro-frontend handles its own routes.

javascript
// Using React Router
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/products/*" element={<ProductsMicroFrontend />} />
        <Route path="/checkout/*" element={<CheckoutMicroFrontend />} />
        <Route path="/dashboard/*" element={<DashboardMicroFrontend />} />
      </Routes>
    </BrowserRouter>
  );
}

// Inside ProductsMicroFrontend
function ProductsMicroFrontend() {
  return (
    <Routes>
      <Route path="/" element={<ProductList />} />
      <Route path="/:id" element={<ProductDetail />} />
    </Routes>
  );
}

Server-Side Routing

Route requests to different micro-frontends.

javascript
// Next.js custom server
const express = require('express');
const next = require('next');

const app = express();
const server = next({ dev: false });

server.prepare().then(() => {
  app.use('/products', (req, res) => {
    server.render(req, res, '/products', req.query);
  });

  app.use('/checkout', (req, res) => {
    server.render(req, res, '/checkout', req.query);
  });

  app.listen(3000);
});

Styling Isolation

CSS Modules

Scoped styles per component.

css
/* Products.module.css */
.productCard {
  padding: 20px;
  border: 1px solid #ddd;
}

/* Different micro-frontend can use same class name */
/* Checkout.module.css */
.productCard {
  padding: 10px;
  background: blue;
}

Shadow DOM

Complete style isolation.

javascript
class MicroFrontend extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .container {
          padding: 20px;
        }
      </style>
      <div class="container">
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('micro-frontend', MicroFrontend);

CSS-in-JS

Component-scoped styles.

javascript
import styled from 'styled-components';

const StyledContainer = styled.div`
  padding: 20px;
  background: ${props => props.theme.background};
`;

function ProductsApp() {
  return (
    <ThemeProvider theme={productsTheme}>
      <StyledContainer>
        Products
      </StyledContainer>
    </ThemeProvider>
  );
}

Testing Strategies

Unit Testing

Test each micro-frontend independently.

javascript
// Products micro-frontend tests
import { render, screen } from '@testing-library/react';
import ProductList from './ProductList';

test('displays products', () => {
  render(<ProductList products={mockProducts} />);
  expect(screen.getByText('Product 1')).toBeInTheDocument();
});

Integration Testing

Test interactions between micro-frontends.

javascript
// Integration test
import { render, screen, fireEvent } from '@testing-library/react';
import App from '../App';

test('adding product updates cart in header', () => {
  render(<App />);

  fireEvent.click(screen.getByText('Add to Cart'));

  expect(screen.getByTestId('cart-badge')).toHaveTextContent('1');
});

Deployment

Independent Deployments

Each micro-frontend deploys separately.

yaml
# CI/CD for checkout micro-frontend
deploy:
  stage: deploy
  script:
    - npm run build
    - aws s3 sync dist/ s3://checkout-microfrontend/
    - aws cloudfront create-invalidation --distribution-id $CDN_ID --paths "/*"
  only:
    - checkout

Canary Releases

Gradual rollout of new versions.

javascript
// Route-based canary
function App() {
  const [useNewCheckout, setUseNewCheckout] = useState(false);

  useEffect(() => {
    // 10% of users get new version
    setUseNewCheckout(Math.random() < 0.1);
  }, []);

  return (
    <>
      {useNewCheckout ? (
        <CheckoutV2 />
      ) : (
        <CheckoutV1 />
      )}
    </>
  );
}

Best Practices

DOs

  • Keep micro-frontends small and focused
  • Use shared libraries for common functionality
  • Implement proper error boundaries
  • Monitor performance independently
  • Version your APIs
  • Document integration points

DON'Ts

  • Create too many micro-frontends
  • Duplicate code across teams
  • Ignore shared dependencies
  • Break backward compatibility
  • Overcomplicate communication
  • Skip integration testing

Performance Optimization

Code Splitting

Load micro-frontends on demand.

javascript
const CheckoutMicroFrontend = lazy(() =>
  import('checkout/CheckoutApp')
);

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <CheckoutMicroFrontend />
    </Suspense>
  );
}

Asset Optimization

Optimize assets per micro-frontend.

javascript
// Vite config
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['react', 'react-dom'],
          'checkout': ['@checkout/ui'],
        },
      },
    },
  },
};

Monitoring

Performance Monitoring

Track each micro-frontend's performance.

javascript
// Using Web Vitals
import { getCLS, getFID, getFCP } from 'web-vitals';

getCLS(console.log);
getFID(console.log);
getFCP(console.log);

// Per micro-frontend
export function reportWebVitals(metric) {
  const analytics = {
    ...metric,
    microFrontend: 'checkout',
  };
  // Send to analytics
}

Error Tracking

Catch and report errors.

javascript
// Error boundary
class MicroFrontendErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    console.error('Micro-frontend error:', error, errorInfo);
    // Send to error tracking
  }

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

Conclusion

Micro-frontends enable teams to work independently and scale frontend development. However, they add complexity. Use them when you have multiple teams, need independent deployments, and have a large codebase. Start simple, monitor performance, and iterate based on feedback.

Enjoyed this essay? Share it.

Related essays

Next essay
Symmetric Encryption: Fast and Efficient Data Protection