DEV Community

Cover image for Solving micro-frontend challenges with Module Federation
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

2

Solving micro-frontend challenges with Module Federation

Written by Peter Aideloje✏️

Proper system architecture and how you shape it is an often overlooked yet crucial measure of success for most modern software companies. The most successful software products today share a common trait: a well-thought-out division of systematic assets and resources.

Micro-frontends have become an effective way to break down monolithic frontend applications into smaller, manageable parts. This approach makes your applications scalable and empowers your team to address complex challenges so that they can deliver high-quality solutions more consistently.

webpack Module Federation is a tool that enables independent applications to share code and dependencies. In this article, we’ll dive into how Module Federation works, its importance in micro-frontends, and strategies to tackle common integration challenges effectively.

What is Module Federation?

webpack Module Federation, introduced in webpack 5, is a feature that allows JavaScript applications to share code and dynamically load modules during runtime.

This modern approach to sharing dependencies eliminates the need for duplication and provides the flexibility to share libraries and dependencies across different applications without creating redundant code. This way, the apps load only the necessary code at runtime.

How does Module Federation work?

Module Federation introduces the concept of a host application and remote application:

  • Host: The application that consumes modules from another application
  • Remote: The application that exposes modules to be consumed by the host

Here’s an example host configuration to demonstrate how you can set up a host and a remote using the ModuleFederationPlugin in webpack:

plugins: [
  new ModuleFederationPlugin({
    name: 'host',
    remotes: {
      app1: 'app1@http://localhost:3001/remoteEntry.js',
    },
    shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
  }),
];
Enter fullscreen mode Exit fullscreen mode
  • name: Defines the name of the host
  • remotes: Specifies the remote application (in this case, app1) and the location of its entry point (remoteEntry.js)
  • shared: Ensures that shared dependencies like React and React DOM use a single version across both applications

Here’s an example of a remote configuration:

plugins: [
  new ModuleFederationPlugin({
    name: 'app1',
    filename: 'remoteEntry.js',
    exposes: {
      './Button': './src/Button',
    },
    shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
  }),
],
Enter fullscreen mode Exit fullscreen mode
  • name: Names the remote application (app1)
  • filename: Specifies the filename where the remote entry point will be available
  • exposes: Indicates which modules (like ./Button) the host can access
  • shared: Same as in the host configuration, ensures consistent dependency versions

Consider a React application setup to better understand how Module Federation works. Imagine two separate React projects:

  1. A Home App that has a product carousel component
  2. A Search App that needs to reuse the same product carousel

The traditional approach would be to extract the carousel into an npm package, then refactor the code and publish it to a private or public npm registry. Then, you probably have to install and update this package in both applications whenever there’s a change. You might discover that this process becomes tedious, time-consuming, and often leads to versioning issues.

Module Federation eliminates this hassle. With it, the Home App continues to own and host the carousel component. Then the Search App dynamically imports the carousel at runtime.

Here’s how it works:

// webpack.config.js for Home App
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'home',
      filename: 'remoteEntry.js',
      exposes: {
        './Carousel': './src/components/Carousel',
      },
      shared: ['react', 'react-dom'], // Share dependencies
    }),
  ],
};
// webpack.config.js for Search App
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'search',
      remotes: {
        home: 'home@http://localhost:3000/remoteEntry.js',
      },
    }),
  ],
};
// Search App dynamically imports the Carousel component
import React from 'react';
const Carousel = React.lazy(() => import('home/Carousel'));
export const App = () => (
  <React.Suspense fallback={<div>Loading...</div>}>
    <Carousel />
  </React.Suspense>
);
Enter fullscreen mode Exit fullscreen mode

Module Federation and micro-frontends: Are they inherently linked?

Module Federation and micro-frontends often go hand in hand, but they are not inherently dependent on each other.

A micro-frontend architecture breaks a monolithic frontend into smaller, independent applications, making development more modular and scalable. Developers can implement micro-frontends using tools like iframes, server-side rendering, or Module Federation.

On the other hand, Module Federation is a powerful tool for sharing code and dependencies between applications. While it complements micro-frontends well, you can also use it independently in monolithic applications.

Can you use Module Federation without micro-frontends?

Absolutely. Module Federation isn’t limited to micro-frontends. For instance, it allows you to share a design system across multiple monolithic applications. It can also dynamically load plugins or features in a single-page application (SPA), eliminating the need to rebuild the entire app for updates.

Are micro-frontends dependent on Module Federation?

No, micro-frontends don’t rely exclusively on Module Federation. You can build them using other methods like server-side includes (SSI), custom JavaScript frameworks, or even static bundling.

However, Module Federation simplifies code sharing and dependency management, which is why it’s a preferred tool for many developers.

Why does Module Federation matter?

Module Federation plays a key role in reducing code duplication and makes it easier to update shared modules across applications. This efficiency ensures that your applications remain lightweight, maintainable, and up-to-date.

Integration challenges with Module Federation

There are many benefits to using Module Federation when you want to build applications that can easily scale. However, as with any technology, implementing it comes with unique challenges. Let’s explore some of the key issues you might face and how to address them effectively.

Styling conflicts with Module Federation

The problem

Multiple teams often use the same CSS framework (like Tailwind CSS) in micro-frontend architectures. If two micro-frontends use global class names, like button or primary-btn, you might experience style overrides or unexpected results.

For example, when the host application applies a button class with a blue background, and the remote application uses a button class with a red background, integrating these applications can cause their styles to override one another. This leads to inconsistent designs that affect the user experience.

The solution

To avoid style conflicts, use Tailwind CSS's prefix option to ensure all class names in the remote application are unique. This isolates your styles and prevents them from clashing with the host application.

To implement this, first add a unique prefix in your tailwind.config.js file:

module.exports = {
  prefix: 'remote-', // Adds 'remote-' to all classes in the remote app
};
Enter fullscreen mode Exit fullscreen mode

With this setup, Tailwind CSS prefixes all class names automatically. For example:

  • btn-primary becomes app1-btn-primary
  • text-lg becomes app1-text-lg

Then, update your components to use the class names with the new prefixes:

const MyButton = () => (
  <button className="remote-btn-primary remote-text-lg">
    Click Me
  </button>
);
Enter fullscreen mode Exit fullscreen mode

This ensures that in the final application, the remote-btn-primary from the remote app won’t interfere with the similarly named host-btn-primary in the host application: Resolving Styling Conflicts With Module Federation

Resolving dependency version mismatches

The problem

Imagine the host application uses React 18.2.0 while the remote application relies on React 17.0.2. This mismatch can result in duplicate React instances, which will break features like useState, useEffect, or shared context.

The solution

To fix this issue, use webpack’s Module Federation to enforce a single version of shared dependencies:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp',
      filename: 'remoteEntry.js',
      remotes: {},
      exposes: {},
      shared: {
        react: {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.2.0',
        },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

This ensures all micro-frontends load only one React version.

If you’re using a monorepo like Nx or Turborepo, enforce consistent versions in your package.json:

{
  "resolutions": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Dependency Version Management With Module Federation

Global state management with micro-frontends

The problem

When micro-frontends share state, it often creates challenges. For example, say you have a host application that manages user authentication, and your remote application controls the shopping cart. Synchronizing user data or passing authentication tokens between the two can quickly spiral into a mess.

The solution

To handle this issue, use a centralized state management tool like Redux, RxJS, or Custom Event APIs*.*

First, create a shared Redux store for cross-micro-frontend communication:

import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
});
export default store;
Enter fullscreen mode Exit fullscreen mode

Then, use window.dispatchEvent and window.addEventListener to broadcast events:

// Host App: Emit login event
window.dispatchEvent(new CustomEvent('user-login', { detail: { userId: '12345' } }));
// Remote App: Listen for login event
window.addEventListener('user-login', (event) => {
  console.log('User logged in:', event.detail.userId);
});
Enter fullscreen mode Exit fullscreen mode

Global State Management With Module Federation

Micro-frontend routing conflicts

The problem

Routing conflicts happen when different micro-frontends define identical or overlapping routes. For example, if both the host and a remote application independently create a /settings route, it can cause unpredictable issues. One route might overwrite the other, or users could end up on the wrong page entirely.

The solution

To resolve routing conflicts, use lazy loading and distinct route namespaces. To prevent interference, ensure each micro-frontend manages its routes independently.

Lazy loading only fetches routes when they’re needed, keeping the routing clean and conflict-free. Here’s how you can implement it:

const routes = [
  { path: '/host', loadChildren: () => import('host/Routes') },
  { path: '/remote', loadChildren: () => import('remote/Routes') },
];
Enter fullscreen mode Exit fullscreen mode

With this setup, navigating to /host loads only the routes defined in host/Routes, while /remote loads routes from remote/Routes. This ensures each application stays isolated and avoids conflicts.

You can also use mamespaces to ensure each micro-frontend has distinct route paths, even for pages that have similar names like /settings.

Here’s an example of namespacing:

  • Host App — /app1/settings
  • Remote App — /app2/settings

    const app1Routes = [
      { path: '/app1/settings', component: SettingsComponent },
      { path: '/app1/profile', component: ProfileComponent },
    ];
    const app2Routes = [
      { path: '/app2/settings', component: SettingsComponent },
      { path: '/app2/notifications', component: NotificationsComponent },
    ];
    

When you use prefixed namespaces (/app1/ and /app2/), you completely avoid route duplication: Avoiding Route Duplication With Module Federation

Dynamic module loading errors

The problem

Dynamic imports in micro-frontends can cause errors if modules fail to load. For example, if the host application incorrectly sets the path to a federated module, it can lead to 404 errors. Imagine the host trying to load a shared component from the remote application, only to crash because the module’s URL is either incorrect or unavailable.

The solution

Set webpack's publicPath correctly to ensure dynamic imports always resolve to the right location.

First, set webpack’s output.publicPath to auto so it dynamically determines the correct path for your modules like this:

module.exports = {
  output: {
    publicPath: 'auto', // Automatically resolves paths for dynamic imports
  },
};
Enter fullscreen mode Exit fullscreen mode

Once the publicPath is set, you can dynamically import a federated module in your React application:

import React from 'react';
// Lazy load the remote component
const MyRemoteComponent = React.lazy(() => import('app2/MyComponent'));
const App = () => (
  <React.Suspense fallback={<div>Loading...</div>}>
    <MyRemoteComponent />
  </React.Suspense>
);
export default App;
Enter fullscreen mode Exit fullscreen mode

This way, React.lazy loads MyComponent from the remote module (app2). If the module takes time to load, the fallback (e.g., “Loading...”) ensures the UI remains responsive: Dynamic Module Loading Errors With Module Federation

Shared resources across micro-frontends

The problem

Micro-frontends usually need to access common assets like images, fonts, styles, or utility functions. Each micro-frontend might duplicate these resources without a centralized approach, which can lead to bloated bundle sizes, inconsistent branding, and slower page loads.

For example, say you have a host application that includes a utility function for formatting dates and a custom font for its UI and a remote application that duplicates the same utility and font files. If you load them together, it can become redundant, waste bandwidth, and hurt performance.

The solution

To streamline the handling of shared resources across your applications, you should centralize them and ensure every micro-frontend has consistent access.

To achieve this, first place shared assets like fonts, stylesheets, or scripts on a CDN or shared server. This ensures all micro-frontends pull from the same source, reducing duplication and improving load performance.

For example, say you want to host a global stylesheet and utilities*.* You can add these to your shared resources:

<link rel="stylesheet" href="https://cdn.example.com/styles/global.css" />  
<script src="https://cdn.example.com/utils.js"></script>  
Enter fullscreen mode Exit fullscreen mode

By leveraging browser caching, updates to these shared resources will automatically reflect across all micro-frontends, improving your app's performance.

Then, to avoid duplicating code, extract reusable functions or utilities into a shared library and publish it for all micro-frontends to use.

For example, say you want to create a date utility function in a shared utils/formatDate.js library:

export const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date);
Enter fullscreen mode Exit fullscreen mode

Publish the library to a private npm registry (e.g., Verdaccio):

npm publish --registry https://registry.example.com/ 
Enter fullscreen mode Exit fullscreen mode

Then, install and use it in micro-frontends:

npm install @myorg/utils  
Enter fullscreen mode Exit fullscreen mode

Finally, use it in your code:

import { formatDate } from '@myorg/utils';  
console.log(formatDate(new Date())); // Output: 12/28/2024 
Enter fullscreen mode Exit fullscreen mode

By hosting shared resources on a CDN and using shared libraries, you can reduce redundancy and ensure consistent behavior across all micro-frontends.

For resources that aren’t always needed, dynamically load them to optimize performance. For example, you can dynamically import a utility like this:

import('https://cdn.example.com/utils.js').then((utils) => {
  const formattedDate = utils.formatDate(new Date());
  console.log(formattedDate);
});
Enter fullscreen mode Exit fullscreen mode

This approach will effectively reduce the initial load time for your application because it will only load what is necessary. It also ensures that your app fetches up-to-date resources when needed: Shared Resources Management With Module Federation

Conclusion

Module Federation is a game changer for managing dependencies and sharing code in your micro-frontend projects. While its integration can be challenging, the best practices we outlined in this guide should help you navigate the most common among them, including styling conflicts, version mismatches, and routing errors.

Happy coding!


Get set up with LogRocket's modern error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now.

Sentry blog image

How to reduce TTFB

In the past few years in the web dev world, we’ve seen a significant push towards rendering our websites on the server. Doing so is better for SEO and performs better on low-powered devices, but one thing we had to sacrifice is TTFB.

In this article, we’ll see how we can identify what makes our TTFB high so we can fix it.

Read more

Top comments (1)

Collapse
 
markuz899 profile image
Marco

Between applications with the same framework, this works correctly, the integration of micro frontends with different frameworks is a little more cumbersome.

Have you by any chance done a test with a host and two remotes of different frameworks?

integration is possible but I repeat more cumbersome what do you think?

Heroku

This site is powered by Heroku

Heroku was created by developers, for developers. Get started today and find out why Heroku has been the platform of choice for brands like DEV for over a decade.

Sign Up

👋 Kindness is contagious

Engage with a sea of insights in this enlightening article, highly esteemed within the encouraging DEV Community. Programmers of every skill level are invited to participate and enrich our shared knowledge.

A simple "thank you" can uplift someone's spirits. Express your appreciation in the comments section!

On DEV, sharing knowledge smooths our journey and strengthens our community bonds. Found this useful? A brief thank you to the author can mean a lot.

Okay