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 } },
}),
];
-
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 } },
}),
],
-
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:
- A Home App that has a product carousel component
- 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>
);
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
};
With this setup, Tailwind CSS prefixes all class names automatically. For example:
-
btn-primary
becomesapp1-btn-primary
-
text-lg
becomesapp1-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>
);
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 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',
},
},
}),
],
};
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"
}
}
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;
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);
});
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') },
];
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:
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
},
};
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;
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:
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>
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);
Publish the library to a private npm
registry (e.g., Verdaccio):
npm publish --registry https://registry.example.com/
Then, install and use it in micro-frontends:
npm install @myorg/utils
Finally, use it in your code:
import { formatDate } from '@myorg/utils';
console.log(formatDate(new Date())); // Output: 12/28/2024
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);
});
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:
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:
- Visit https://logrocket.com/signup/ to get an app ID.
- 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');
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>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (1)
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?