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.
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.
Module Federation introduces the concept of a host application and remote application:
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 hostremotes
: 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 applicationsHere’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 availableexposes
: Indicates which modules (like ./Button
) the host can accessshared
: Same as in the host configuration, ensures consistent dependency versionsConsider a React application setup to better understand how Module Federation works. Imagine two separate React projects:
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 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.
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.
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.
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.
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.
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.
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
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> );
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:
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.
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" } }
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.
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); });
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.
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:
/app1/settings
/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 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.
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:
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.
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:
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!
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowWhether you’re part of the typed club or not, one function within TypeScript that can make life a lot easier is object destructuring.
useState
useState
can effectively replace ref
in many scenarios and prevent Nuxt hydration mismatches that can lead to unexpected behavior and errors.
Explore the evolution of list components in React Native, from `ScrollView`, `FlatList`, `SectionList`, to the recent `FlashList`.
Explore the benefits of building your own AI agent from scratch using Langbase, BaseUI, and Open AI, in a demo Next.js project.