Optimizing Micro-Frontend Architecture: Dynamic URLs for Module Federation in React
The evolution of modern web applications has led to the adoption of increasingly flexible and scalable architectures. Among these, the micro-frontend approach has distinguished itself for its ability to manage complex applications in a modular way. In this article, we will explore a solution to further improve this architecture: the dynamic loading of remote URLs in a React application using Module Federation.
The Context: Architecture
Before delving into the specific problem and its solution, it’s important to understand the general architecture we’re operating in. The architecture in question is based on the concept of micro-frontend, implemented through Webpack’s ModuleFederationPlugin.
In this architecture, we have:
- a main application, which we’ll call the container
- several sub-applications, or remotes, which are hosted and integrated within the container
The container serves as the entry point and orchestrator for the entire application. It is responsible for loading and integrating the various remote sub-applications within itself. These sub-applications are essentially independent modules that can be developed, tested, and deployed separately, but work together to form a cohesive application from the end user’s perspective.
N.B. Sometimes there might be dependencies between the various actors involved, for example due to sharing a state, such as authentication, but for the purpose of this article, this aspect is not relevant.
The Problem
In traditional Module Federation implementations, the URLs of the remotes are typically hardcoded in the Webpack configuration.
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'container',
remotes: {
childApp1: 'childApp1@http://fixed-url:3000/remoteEntry.js',
},
}),
],
};
While simple, this approach can lead to various issues in real-world scenarios. Let’s examine two concrete examples that illustrate the limitations of this solution:
- Change of domain or hosting URL
- Alternating between different versions of an application
1. Change of domain or hosting URL
Imagine we have a remote application hosted on a specific domain. What happens if that domain is no longer available and we need to move the application to a new URL? In this scenario, the remote application remains identical, but its hosting address changes.
2. Alternating between different versions of an application
Consider a case where we have two slightly different versions of the same remote application. We might want to switch from one version to another in real-time, based on certain needs or conditions.
In both these scenarios, using hardcoded values in the Webpack configuration proves problematic. Any modification, even minor, to the remote URLs requires a new build and deployment of the container application.
Therefore, the need is to find a way to dynamically manage these URLs, allowing for rapid and flexible changes without the need to recompile and redistribute the entire application.
The Solution
The proposed solution is divided primarily into 2 steps:
- an external JSON configuration file that contains the addresses of the remotes
- a custom Promise in the Webpack configuration for each imported remote
1. JSON Configuration File
We have created a file called appUrl.json and placed it in a folder named config. It’s crucial to note that this folder must be located in the same directory where the application build resides. This positioning is fundamental because in the API call to retrieve the file, we will use a relative path.
In a production environment, for example on an Nginx server, our config/appUrl.json folder must be present in the same directory as the static files served by the server.
In a local development environment, on the other hand, we can use th e folder public directly, so we’ll have public/config/appUrl.json. Here, we’ll likely have localhost:3000, 3001, and so on as the values for the remotes.
The choice to use a relative path to access this file is strategic. If the file were positioned in a different location, we would be forced to use an absolute URL or a fixed environment variable just to load our JSON, thus compromising the flexibility we’re trying to achieve with this solution.
A key advantage of this solution is its operational flexibility: the client themselves or the system administrator can dynamically modify the addresses of specific micro-frontends of the main application in real-time, simply by updating this JSON file.
2. Custom Promise in Webpack
Instead of directly specifying the URL of the remote, we use a Promise, as suggested in the Webpack documentation under the Promise-based dynamic remotes module, which:
- Retrieves a JSON configuration file
- Dynamically loads the remote’s script
- Initializes the remote’s container
Here’s an example of how this configuration might appear for a remote called child1:
child1: `promise new Promise((resolve, reject) => {
fetch('/config/appUrl.json')
.then(response => {
if (!response.ok) { throw new Error('Response from the config/appUrl.json was not ok ' + response.statusText); }
return response.json();
})
.then(config => {
const remoteUrlWithVersion = config.URL_ORIGIN_CHILD1;
if (!remoteUrlWithVersion) {throw new Error('URL_ORIGIN_CHILD1 not found in config'); }
const script = document.createElement('script');
script.src = remoteUrlWithVersion + "/js/child1-app-entry.js";
script.onload = () => {
try {
//console.log('Script loaded:', script.src);
if (!window.Child1App) {
console.error('Remote container "Child1App" not found on window object');
// Resolve with a placeholder or default behavior if needed
resolve({ get: () => { }, init: () => { } });
} else {
const proxy = {
get: (request) => window.Child1App.get(request),
init: (arg) => {
try {
return window.Child1App.init(arg);
} catch (e) {
console.log('Remote container already initialized', e);
}
}
};
resolve(proxy);
}
} catch (error) {
console.error('Error during script load or initialization:', error);
// Resolve with a placeholder or default behavior if needed
resolve({ get: () => { }, init: () => { } });
}
};
script.onerror = () => {
console.error('Failed to load script from Child1App:', script.src);
// Resolve with a placeholder or default behavior if needed
resolve({ get: () => { }, init: () => { } });
};
document.head.appendChild(script);
})
.catch(error => {
console.error('Error fetching the config file:', error);
reject(error);
});
});`;
Conclusion
I hope this article has been helpful in understanding and implementing dynamic remote loading in micro-frontend architectures.
If you’re looking to start using micro-frontends with Webpack Module Federation, feel free to check out my GitHub repository here for a ready-to-use template. This template can serve as a solid starting point for your micro-frontend projects with React, Typescript and Webpack.
You will also have the opportunity to directly test what we have covered here, so happy coding and see you next time 👋!