It’s time to release your latest front-end changes to production. However, as you create a new tag, you notice a large number of commits unrelated to the feature you’ve been working on. Now, instead of simply kicking off your deployment and watching the pipeline run, you need to check with numerous developers and managers to ensure you don’t deploy any breaking changes to production.
As an application grows and becomes more complex, with more teams and developers working on it, this scenario occurs with increasing frequency. The root cause often boils down to one thing: the need to share code. We want our frontend applications to have a consistent theme, behavior, and feel, which we achieve by sharing the same components and utilities throughout.
To achieve this, we typically follow one of two approaches: either we build a large monolithic application, which ends up tightly coupled and can lead to maintenance and scaling difficulties, or we create numerous individual applications interconnected by a web of npm packages, which comes with the challenges of dependency management and code duplication. However, there is a way to maintain this consistency while avoiding the pitfalls of tight coupling and complex dependencies. Enter micro frontends and Module Federation.
In this blog, we will introduce how to manage complex frontend deployments using micro frontends and Webpack 5’s Module Federation. As your application grows, shared code across teams can lead to unintended changes in production. We’ll show how micro frontends and Module Federation help maintain a consistent user experience while avoiding the challenges of tight coupling and complex dependencies, enabling a more modular and scalable frontend architecture.
What are Micro Frontends with Module Federation?
Micro frontends are a design approach that extends the concept of microservice to the frontend. A web application is divided into different modules, each developed, tested, and deployed independently. This design approach helps address the coupling, maintenance, and scaling challenges of a monolithic frontend. But how does it tackle the issue of code sharing? That’s where Module Federation comes into play.
Module Federation is a feature added to Webpack 5 that enables developers to build micro frontends—and much more. As stated in their documentation, the motivation behind Module Federation is to allow “multiple separate builds [to] form a single application. These separate builds act like containers and can expose and consume code among themselves, creating a single, unified application. This is often known as Micro-Frontends, but is not limited to that.”
The key differentiator of Module Federation is when this code sharing happens, which is runtime. With Module Federation, your webpack bundle can include both local and remote modules. Local modules are part of the local build, while remote modules are loaded at runtime from a remote container. This runtime code sharing doesn’t just enable live updates and eliminate npm package management—it opens the door to a new level of flexibility in how your application is structured. You’re no longer confined to just dividing a UI by services; you can now modularize your app in ways that best suit your needs:
- Entire sections of your application can be developed and deployed independently.
- Specific pages can be their own separate builds, allowing for targeted updates and deployments.
- Features on a page, like a shopping cart or checkout flow, can be isolated into distinct modules.
- Even components, such as a custom UI library, can be independently developed and deployed, enabling all your frontend applications to consume them in real time.
Enough Talk, Let’s See Some Code
Module Federation was introduced in Webpack 5, so you’ll need to be on version 5 to use it. The good news is that for most use cases, you won’t need any additional npm packages or plugins to implement it. Let’s quickly define a few key terms before diving into the code implementation:
- Container: Every webpack build acts like a container. So for consistency when used here we are referring to a webpack build.
- Host: The application or build that is consuming from a remote container. This application acts as the “host” for remote code chunks.
- Remote: The build that is exposing a container that a host can consume.
Configure the Remote
We’ll start by configuring the Remote application. This might seem counterintuitive, but it ensures that a container is available for a Host to consume.
In your webpack.config.js
file, import the Module Federation plugin directly from Webpack:
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
Next, initialize the ModuleFederationPlugin
in the plugins
section:
plugins: [ new ModuleFederationPlugin({ name: "productDescription", filename: "remoteEntry.js", exposes: { './Heading': './src/components/Heading.jsx', './Description': './src/components/Description.jsx', }, shared: { ...deps, react: { singleton: true, requiredVersion: deps.react, }, "react-dom": { singleton: true, requiredVersion: deps["react-dom"], }, }, }), ];
Let’s break down the important pieces you need to understand to modify this configuration for your use case:
- name: This is your application’s Federated Module name. Other Webpack containers will use this name to access this container. It doesn’t have to match your application’s name, but it should be intuitive.
- filename: Webpack will create a file containing all exposed modules of this container. This is the name of the file for this container.
- exposes: This specifies which files, components, or functions are made available for other containers.
- The left side of the colon is the name that it will be made available under in the Host application.
- The right side is the path in the Remote from the root directory to the locally exported item.
- shared: Webpack allows us to share dependencies between containers. This can reduce dependencies across applications as well as decrease bundle sizes.
- singleton: Treat this dependency as a singleton, meaning no matter how many times React is loaded in this application, we only want one instance.
- requiredVersion: Ensures that the version of the dependency is compatible across all parts of the application. In this example, the
deps
are imported from ourpackage.json
, so the required version of React is"^18.2.0"
.
Configure the Host
Now that we’ve made our modules available, let’s use them in the Host application. First, import the plugin directly from Webpack in your webpack.config.js
file:
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
Then, initialize the plugin to fetch remote containers:
plugins: [ new ModuleFederationPlugin({ name: "shell", remotes: { productDescription: 'productDescription@http://localhost:3001/remoteEntry.js', }, }), ];
To configure our Host, we only need to add two fields:
- name: The application’s Federated Module name.
- remotes: This object defines which remote containers to pull from at runtime.
- key: This is the Module Federation name of the Remote container.
- value: To the left of the
@
is the key you will use to reference the remote module in your imports. It’s a good practice to keep this the same as the Remote name unless you have a compelling reason to change it. To the right of the@
is the URL where the Remote is hosted, which ends at the filename we created in the Remote config.
Using the Imported Modules
Now that everything is configured, let’s use these imported modules in your Host application:
import React, { Suspense, lazy } from "react"; import { MainHeader } from '../components/MainHeader'; const Heading = lazy(() => import("productDescription/Heading")); const Description = lazy(() => import("productDescription/Description")); const ProductPage = () => { return ( <div> <MainHeader /> <Suspense fallback={<div>Loading...</div>}> <Heading text="Product Heading From Host" /> </Suspense> <Suspense fallback={<div>Loading...</div>}> <Description text="Product description from Host" /> </Suspense> </div> ); }; export default ProductPage;
Using Suspense
and lazy
in React allows us to load the remote components asynchronously, again at runtime. Once we have imported them, we can use them exactly the same as if they were written in our Host application.
Host and Remote Combined
Yes, you can create an application that is both a Host and a Remote. This isn’t very complex; it simply involves combining the Host and Remote configurations when initializing the plugin:
plugins: [ new ModuleFederationPlugin({ name: "productDescription", filename: "remoteEntry.js", remotes: { uiLibrary: 'uiLibrary@http://localhost:3002/remoteEntry.js', }, exposes: { './Heading': './src/components/Heading.jsx', './Description': './src/components/Description.jsx', }, shared: { ...deps, react: { singleton: true, requiredVersion: deps.react, }, "react-dom": { singleton: true, requiredVersion: deps["react-dom"], }, }, }), new HtmlWebPackPlugin({ template: "./src/index.html", }), new Dotenv(), ];
Dynamic Remote Imports
As every engineer knows, there will always be use cases that require customization in your configuration setup, and Module Federation is no exception. To accommodate this, remotes in a Remote container can be a promise. By using a promise, you can access different runtime variables to match the correct Remote with your Host.
plugins: [ new ModuleFederationPlugin({ name: "shell", remotes: { productDescription: `promise new Promise((resolve, reject) => { const params = new URLSearchParams(window.location.search) const version = params.get(‘version’) const remoteEntry = 'http://localhost:3001' + version + '/remoteEntry.js'; const script = document.createElement('script'); script.src = remoteEntry; script.onload = () => { if (window.productDescription) { console.log('Remote loaded:', window.productDescription); const proxy = { get: (request) => window.productDescription.get(request), init: (arg) => { try { return window.productDescription.init(arg); } catch (e) { console.log('Remote container already initialized'); } }, }; resolve(proxy); } else { console.error('Remote not found on window:', window.productDescription); reject(new Error('Remote not loaded correctly.')); } }; script.onerror = () => { console.error('Failed to load remote script:', remoteEntry); reject(new Error('Failed to load remote script.')); }; document.head.appendChild(script); })`, }, }), ];
In this example, a version of a container can be selected depending on the version of the Host. While this setup is flexible, you need to include two key elements when writing your own promise:
- Script: Since we’re manually managing what Module Federation typically handles, we need to create a script element to initialize the container upon successful loading.
- get/init Interface: We need to implement the get and init methods to interact with our remote container. These methods initialize the remote container and retrieve modules from it.
But It’s Not All Good News
While Module Federation offers many benefits for implementing micro frontends, there are also some drawbacks that you and your team should consider before choosing to implement it. Here are a few key points to keep in mind:
- Module Federation’s Dilemma: For simple systems, the complexity of Module Federation isn’t justified. Yet, as systems grow complex enough to benefit from it, the implementation itself can be challenging and comes with significant overhead.
- Network Dependent: It adds runtime API requests and associated latency. The code becomes more complex as you’ll need to handle error states and fallbacks for these API requests. Overall availability decreases as you add more independently deployed applications.
- Dependency Management and Maintenance: While simplified this cannot be ignored if you want to keep bundle sizes minimal and avoid runtime errors caused by version mismatches.
- Security Concerns: More code is exposed over network connections, potentially increasing security risks. To implement it, you may need to relax your Content Security Policy (CSP) and Cross-Origin Resource Sharing (CORS) configurations, which could further expose your application to vulnerabilities.
While there are drawbacks, and it should not be viewed as a silver bullet for all frontend deployment and code-sharing issues, incorporating Module Federation and micro frontends into your toolkit opens up exciting new possibilities for addressing these challenges. By carefully considering when and how to use Module Federation alongside other tools, you can build robust and maintainable frontends.
Conclusion
As frontend applications grow in complexity, maintaining consistency and preventing deployment challenges become increasingly important. Micro frontends and Webpack 5’s Module Federation offer a strategic solution by enabling independent development and deployment of modules while ensuring a cohesive user experience. Although these approaches introduce some complexity, their benefits in flexibility and scalability are significant. By implementing these tools, you can achieve a more robust and maintainable frontend architecture.
For more coding insights and techniques, explore our other posts on the Keyhole Software blog, and don’t forget to follow Keyhole on social media.
Got questions? Drop us a comment below!