Comprehensive Guide to Microfrontends


Introduction to Microfrontends

Microfrontends extend the principles of microservices to the frontend by splitting a monolithic UI into smaller, more manageable, and independently deployable components. Each microfrontend (MFE) is an isolated application responsible for a specific piece of functionality. For example, in an e-commerce platform, the homepage, product detail page (PDP), and shopping cart could be implemented as separate microfrontends.

Why Use Microfrontends?

  1. Scalability: Teams can work independently on different parts of the application without stepping on each other’s toes.

  2. Faster Deployment: Independent deploy cycles for each MFE enable rapid iterations.

  3. Technology Flexibility: Each MFE can use a different tech stack if necessary.

  4. Improved Maintainability: Isolated codebases reduce complexity and technical debt.


Setup and Implementation

Step 1: Setting Up Applications

Each microfrontend is its own application. Tools like create-mf-app can simplify this process. Below is an example setup:

  1. Create Applications:

    • Home Application: Acts as the host.

    • PDP Application: Loads product details.

    • Cart Application: Manages shopping cart functionality.

  2. Choose Frameworks and Tools:

    • React with Webpack 5 and Tailwind CSS for styling.

Step 2: Sharing Components

Shared components like headers or footers can be exposed using Webpack’s Module Federation plugin:

// webpack.config.js in Home App
new ModuleFederationPlugin({
  name: "home",
  filename: "remoteEntry.js",
  exposes: {
    "./Header": "./src/components/Header",
    "./Footer": "./src/components/Footer",
  },
});

In the PDP App, these components are consumed as remotes:

new ModuleFederationPlugin({
  remotes: {
    home: "home@http://localhost:3000/remoteEntry.js",
  },
});

What Can Be Shared Between Applications

Webpack's Module Federation allows sharing more than just UI components. Below is a list of items that can be shared between applications:

  1. Components: Reusable UI components such as headers, footers, buttons, etc.

  2. JSON Data: Encoded JSON files or dynamically imported data structures.

  3. Utility Functions: Helper functions for common operations like formatting or validation.

  4. Constants: Shared constant values like API URLs or configuration keys.

  5. Styles: Shared stylesheets or design tokens, provided they are compatible with both applications.

  6. Modules: Full modules or libraries, including authentication handlers or state management tools.

  7. APIs: Functions for making API requests or sharing services like authentication.


Sharing JWT Tokens Between Microfrontends

Sharing JWT tokens securely is critical in a microfrontend architecture to enable seamless authentication across the system.

Approach 1: Local Storage or Cookies

Store the JWT in localStorage or cookies at the host level. Each microfrontend accesses the token when needed:

// Set token in localStorage
localStorage.setItem('authToken', token);

// Access token in microfrontend
const token = localStorage.getItem('authToken');

Security Tip: Avoid storing sensitive data in localStorage. Use HttpOnly cookies for added security.

Approach 2: Context API for Token Sharing

Use React’s Context API to provide the token to all microfrontends:

const AuthContext = React.createContext();

export const AuthProvider = ({ children }) => {
  const [authToken, setAuthToken] = React.useState(null);

  return (
    <AuthContext.Provider value={{ authToken, setAuthToken }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => React.useContext(AuthContext);

Each microfrontend can consume the token from the context:

const { authToken } = useAuth();

Sharing the Cart Between Microfrontends

A shared shopping cart is a common requirement in e-commerce microfrontends. Below are ways to implement it:

Approach 1: Shared State Using RxJS

Use an observable to manage and share cart state:

import { BehaviorSubject } from 'rxjs';

const cart$ = new BehaviorSubject([]);

export const addToCart = (item) => {
  cart$.next([...cart$.getValue(), item]);
};

export const getCart = () => cart$.getValue();

export const cartObservable = cart$;

Microfrontends can subscribe to updates:

cartObservable.subscribe((cart) => {
  console.log('Updated cart:', cart);
});

Approach 2: API-Driven Cart

Use a centralized API to manage the cart:

// Add to cart
fetch('/api/cart', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${authToken}`,
  },
  body: JSON.stringify(item),
});

// Get cart
fetch('/api/cart', {
  headers: {
    Authorization: `Bearer ${authToken}`,
  },
}).then((response) => response.json());

Approach 3: Context API for Cart State

Leverage Context API for sharing cart state:

const CartContext = React.createContext();

export const CartProvider = ({ children }) => {
  const [cart, setCart] = React.useState([]);

  return (
    <CartContext.Provider value={{ cart, setCart }}>
      {children}
    </CartContext.Provider>
  );
};

export const useCart = () => React.useContext(CartContext);

Routing in Microfrontends

Routing in microfrontends can be handled using React Router or similar libraries. Here’s how it works:

  1. Parent Routing: The host application manages high-level routes, delegating control to microfrontends:

     <BrowserRouter>
       <Routes>
         <Route path="/home" element={<Home />} />
         <Route path="/pdp/*" element={<PDP />} />
         <Route path="/cart" element={<Cart />} />
       </Routes>
     </BrowserRouter>
    
  2. Child Routing: Each microfrontend handles its own internal routing using React Router:

     <Routes>
       <Route path="/details/:id" element={<ProductDetails />} />
     </Routes>
    
  3. History Management: Ensure history objects are shared properly between microfrontends to enable seamless navigation.


State Management in Microfrontends

State management can be challenging in microfrontend architectures due to the distributed nature of the system. Below are common approaches:

Shared State Using RxJS

RxJS can be used to maintain and share state across microfrontends without coupling them tightly:

import { BehaviorSubject } from 'rxjs';

const sharedState$ = new BehaviorSubject({});

export const setSharedState = (newState) => {
  sharedState$.next({ ...sharedState$.getValue(), ...newState });
};

export const getSharedState = () => sharedState$.getValue();

export default sharedState$;

Each microfrontend can subscribe to sharedState$ and update its own state accordingly.

Context API

For React-based applications, the Context API can be used to share state within a microfrontend or between closely related ones:

const GlobalContext = React.createContext();

export const GlobalProvider = ({ children }) => {
  const [state, setState] = React.useState({});

  return (
    <GlobalContext.Provider value={{ state, setState }}>
      {children}
    </GlobalContext.Provider>
  );
};

export const useGlobalContext = () => React.useContext(GlobalContext);

API-Driven State

In some cases, state can be derived from a centralized API. This approach reduces duplication but may introduce latency issues.


Host and Remote Communication

In a microfrontend architecture, the host application orchestrates remotes. Below are key concepts:

Host Application

  • The host manages high-level routing and provides shared resources (e.g., state or libraries).

  • Example Webpack Configuration:

new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
  },
  shared: {
    react: { singleton: true },
  },
});

Remote Applications

  • Remotes define and expose functionality to be consumed by the host.

  • Example Configuration:

new ModuleFederationPlugin({
  name: 'remoteApp',
  filename: 'remoteEntry.js',
  exposes: {
    './Widget': './src/Widget',
  },
  shared: {
    react: { singleton: true },
  },
});

Runtime Communication

Host and remote applications communicate dynamically, enabling real-time updates without redeployment.


Singletons in Module Federation

Singletons are a critical feature of Webpack’s Module Federation to ensure consistent versions of shared libraries. For instance, in React-based applications, React must be a singleton to avoid conflicts.

Webpack Configuration for Singletons

The shared key in the Module Federation configuration allows for setting up singleton mode:

new ModuleFederationPlugin({
  shared: {
    react: {
      singleton: true,
      requiredVersion: '^17.0.0',
    },
    'react-dom': {
      singleton: true,
      requiredVersion: '^17.0.0',
    },
  },
});

Benefits of Singleton Mode

  • Ensures that only one version of a library is loaded.

  • Avoids issues with libraries like React that rely on a single global instance.


Deployment Considerations

  1. Static Asset Stores: Deploy microfrontends as static assets to a CDN like AWS S3.

  2. Cache Management: Use unique filenames for builds to ensure updates are served correctly.

  3. Versioning: Implement a fallback mechanism in case of API contract changes.


Comparison with Alternatives

FeatureModule FederationNPM PackagesAsset Store
Runtime SharingYesNoYes
Version ManagementAutomaticManualManual
Build ComplexityModerateHighLow

Illustrative Diagrams

Microfrontend Architecture

Microfrontend Architecture

State Sharing

State Sharing


Cross-Platform Micro-Frontends

Cross-platform micro-frontends aim to provide consistent functionality and user experience across web, mobile, and desktop platforms. This strategy leverages shared components, unified state management, and modular architectures to deliver platform-specific variations with minimal duplication.

Key Benefits

  1. Code Reusability: Shared business logic and components reduce redundancy.

  2. Consistent User Experience: Unified design systems ensure visual consistency.

  3. Efficient Maintenance: Single codebase for shared logic minimizes maintenance overhead.

  4. Platform Optimization: Customization for platform-specific capabilities (e.g., touch interactions for mobile).

Technological Stack

  1. Web:

    • Framework: React, Vue.js, or Angular.

    • Bundler: Webpack or Vite with Module Federation.

  2. Mobile:

    • Framework: React Native, Flutter, or Swift/Kotlin (with shared libraries).
  3. Desktop:

    • Framework: Electron or Tauri for cross-platform desktop apps.

Architecture Overview

  1. Shared Layer:

    • Shared components, business logic, and state management are housed in this layer.
  2. Platform-Specific Layers:

    • Web: Uses web-specific styling and responsive design.

    • Mobile: Includes platform-specific components like ScrollView in React Native.

    • Desktop: Optimized for window management and offline capabilities.

Implementation Strategy

  1. Shared Component Library: Create a shared component library using tools like Storybook. These components should be platform-agnostic:

     // Button.js
     export const Button = ({ onPress, children }) => {
       return (
         <button onClick={onPress} style={styles.button}>
           {children}
         </button>
       );
     };
    
  2. State Management: Use libraries like Redux or Zustand with adapters for platform-specific requirements:

     const sharedState = createStore({
       cart: [],
       user: null,
     });
    
  3. Platform Adapters: Build adapters for platform-specific needs:

     // Adapter for mobile gestures
     export const usePlatformGestures = () => {
       return Platform.OS === 'web' ? useMouseGestures() : useTouchGestures();
     };
    
  4. Webpack Module Federation: Enable sharing of business logic and UI components across platforms:

     new ModuleFederationPlugin({
       name: "host",
       remotes: {
         mobile: "mobile@http://localhost:3001/remoteEntry.js",
         web: "web@http://localhost:3002/remoteEntry.js",
       },
       shared: ["react", "redux"],
     });
    

Challenges and Solutions

  1. Platform-Specific Styles:

    • Solution: Use libraries like Tailwind or Styled Components with platform-specific themes.
  2. Performance Optimization:

    • Solution: Optimize shared components for the lowest common denominator, and enhance with platform-specific extensions.
  3. State Management Conflicts:

    • Solution: Namespace state slices for platform-specific logic.

Micro-Frontend Routing

Routing in micro-frontends is essential for enabling seamless navigation between independently deployed modules while maintaining a unified user experience. This section outlines the strategies and implementation techniques for routing in micro-frontend architectures.

Approaches to Routing

  1. Host-Managed Routing: The host application manages high-level routes and delegates rendering responsibilities to micro-frontends.

     <BrowserRouter>
       <Routes>
         <Route path="/home" element={<Home />} />
         <Route path="/pdp/*" element={<PDP />} />
         <Route path="/cart" element={<Cart />} />
       </Routes>
     </BrowserRouter>
    
  2. Micro-Frontend Local Routing: Each micro-frontend manages its own internal routes for pages specific to its domain.

     <Routes>
       <Route path="/details/:id" element={<ProductDetails />} />
       <Route path="/reviews/:id" element={<ProductReviews />} />
     </Routes>
    
  3. Dynamic Route Injection: Routes are dynamically added to the host application as micro-frontends are loaded.

     const loadMicroFrontend = (route, element) => {
       router.addRoute({ path: route, element });
     };
    
     loadMicroFrontend('/pdp', PDPComponent);
    
  4. Route Synchronization: Synchronize the history object across micro-frontends to maintain consistent navigation state.

     import { createBrowserHistory } from 'history';
    
     const globalHistory = createBrowserHistory();
    
     // Share the global history instance
     export default globalHistory;
    

Handling Navigation Conflicts

  • Namespace Routes: Prefix routes for each micro-frontend to avoid collisions (e.g., /cart, /pdp).

  • Fallbacks: Implement fallback routes to handle unmatched paths.

      <Route path="*" element={<NotFound />} />
    

Cross-Micro-Frontend Navigation

Enable communication between micro-frontends for navigation events.

// Host app
const navigateTo = (path) => {
  globalHistory.push(path);
};

// Micro-frontend consuming the navigation API
navigateTo('/cart');

Best Practices

  1. Route Isolation: Keep routes isolated within micro-frontends to ensure modularity.

  2. Shared Utilities: Use shared navigation utilities to manage cross-frontend navigation.

  3. Error Handling: Add error boundaries to manage navigation failures gracefully.
    Conclusion

Microfrontends enable scalability, flexibility, and faster development cycles for large-scale applications. By following best practices and leveraging tools like Module Federation, you can build robust, maintainable systems that cater to modern development needs.

For more details, refer to the provided examples and expand as necessary!