DEV Community

Cover image for Micro-frontends: Module Federation with WebPack 5
Brandon Villa
Brandon Villa

Posted on • Edited on

Micro-frontends: Module Federation with WebPack 5

What is Module Federation?

It is basically a JavaScript architecture. It allows a JavaScript application to dynamically load code from another application (a different Webpack build).

This is how you normally use Webpack

You would use Webpack to generate a bundle for production or development, let's say Webpack helps you to generate a folder called dist and a file main.js within this folder. This is the result of all of your JavaScript code that you normally have in a folder called src

The more you add code into your src folder the heavier is this main.js file which Webpack generates. Remember that this is the file you take to your production environment and clients download in their browsers, if this file is heavy that means it will take longer for the users to load your page.

That means we care about the size of our bundle but we also want to keep adding new features to our projects

Is there a solution to this problem?

There is, there are strategies to break that main.js file into chunks of smaller files in order to avoid loading all your code at first render. This is called Code Splitting (https://webpack.js.org/guides/code-splitting/)

There are different techniques to accomplish this, one is defining more than one entry point into your Webpack configuration but it comes with some pitfalls, sometimes you will have duplicated modules between chunks and both chunks will include these modules so it will increase the size of your chunks.

There's another popular and more accepted way, this consists in using the import() syntax which conforms to the ES Proposal in order to have dynamic imports in JS (https://github.com/tc39/proposal-dynamic-import)

Using this approach looks something like this:

function test() {
  import('./some-file-inside-my-project.js')
    .then(module => module.loadItemsInPage())
    .catch(error => alert('There was an error'))
}
Enter fullscreen mode Exit fullscreen mode

We can lazy load the elements to our page using import() syntax and also this will create a new chunk which will get loaded on demand

But what if I told you that there's another way to break this main.js file not only into different chunks but into different projects?

Here is where Module Federation comes

With Module Federation you can import remote Webpack builds to your application. Currently, you could import these chunks but they would have to come from your same project. Now, you can have these chunks (Webpack builds) from a different origin, which means, a different project!

Module Federation in action

To explain what all of this is about, we will see some code samples of a Webpack configuration using ModuleFederationPlugin and some React.js code

For this, we will use Webpack 5 which currently is on version beta. This is how the package.json file looks like:

// package.json (fragment)

...

  "scripts": {
   "start": "webpack-dev-server --open",
   "build": "webpack --mode production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "7.10.3",
    "@babel/preset-react": "7.10.1",
    "babel-loader": "8.1.0",
    "html-webpack-plugin": "^4.3.0",
    "webpack": "5.0.0-beta.24",
    "webpack-cli": "3.3.11",
    "webpack-dev-server": "^3.11.0"
  },
  "dependencies": {
    "react": "^16.13.1",
    "react-dom": "^16.13.1"
  }

...
Enter fullscreen mode Exit fullscreen mode

We have included all the Webpack modules to create a basic setup for a React application

This is how the webpack.config.js looks so far:

// webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

module.exports = {
  entry: './src/index',
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    port: 3000,
  },
    output: {
    publicPath: "http://localhost:3000/",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

This is a normal configuration of Webpack

Let's add a react component to the project:

// src/index.js

import React from 'react';
import ReactDOM from 'react-dom';

function App() {
  return (
    <h1>Hello from React component</h1>
  )
}

ReactDOM.render(<App />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

At this point if you run this project, you will get a page which will show a message saying "Hello from React component". Until now, there's nothing new here.

The code of this project until this step is here:https://github.com/brandonvilla21/module-federation/tree/initial-project

Creating a second project

Now, we will create a second project with the same package.json file but with some differences under the Webpack configuration:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

// Import Plugin
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  entry: './src/index',
  mode: 'development',
  devServer: {
    contentBase: path.join(__dirname, 'dist'),
    // Change port to 3001
    port: 3001,
  },
    output: {
    publicPath: "http://localhost:3001/",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react'],
        },
      },
    ],
  },
  plugins: [
    // Use Plugin
    new ModuleFederationPlugin({
      name: 'app2',
      library: { type: 'var', name: 'app2' },
      filename: 'remoteEntry.js',
      exposes: {
        // expose each component you want 
        './Counter': './src/components/Counter',
      },
      shared: ['react', 'react-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

We're importing the ModuleFederationPlugin on top of the configuration

const { ModuleFederationPlugin } = require('webpack').container;
Enter fullscreen mode Exit fullscreen mode

We also need to change the port since we will be running both applications at the same time

port: 3001,
Enter fullscreen mode Exit fullscreen mode

And this is how the Plugin config looks like:

new ModuleFederationPlugin({
  name: 'app2', // We need to give it a name as an identifier
  library: { type: 'var', name: 'app2' },
  filename: 'remoteEntry.js', // Name of the remote file
  exposes: {
    './Counter': './src/components/Counter', // expose each component you want 
  },
  shared: ['react', 'react-dom'], // If the consumer application already has these libraries loaded, it won't load them twice
}),
Enter fullscreen mode Exit fullscreen mode

This is the main piece of configuration in order to share the dependencies of this second project with the first one.

Before consuming this second application from the first one, let's create the Counter component:

// src/components/Counter.js

import React from 'react'

function Counter(props) {
  return (
     <>
       <p>Count: {props.count}</p>
       <button onClick={props.onIncrement}>Increment</button>
       <button onClick={props.onDecrement}>Decrement</button>
     </>
  )
}

export default Counter
Enter fullscreen mode Exit fullscreen mode

This is a very common example but the point here is to show how can we use this component and pass some props from the first application

If you try to run the second app at this point adding a basic index.js like what we did on the first application, you will likely get a message saying the following:

Uncaught Error: Shared module is not available for eager consumption
Enter fullscreen mode Exit fullscreen mode

As the error says, you're eagerly executing your application. In order to provide an async way to load the application we can do the following:

Create a bootstrap.js file and move all your code from index.js to this file

// src/bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';

function App() {
  return <h1>Hello from second app</h1>;
}

ReactDOM.render(<App />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

And import it in index.js like this: (notice we're using import() syntax here)

// src/index.js

import('./bootstrap')
Enter fullscreen mode Exit fullscreen mode

Now if you run the second project at this point, you will be able to see the message Hello from second app

Importing Counter component to the first project

We will need to update the webpack.config.js file first, in order to consume the Counter component from the second app

// webpack.config.js (fragment)

...
plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      library: { type: 'var', name: 'app1' },
      remotes: {
        app2: 'app2', // Add remote (Second project)
      },
      shared: ['react', 'react-dom'],
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
...
Enter fullscreen mode Exit fullscreen mode

The difference between this Webpack config and the other relies on expose and remote. Where in the first app, we expose the component that we want to take from the first app, so in this app, we specify the name of the remote app

We also need to specify the remoteEntry.js file from the remote host:

<!-- public/index.html (fragment)-->

...
<body>
  <div id="root"></div>
  <script src="http://localhost:3001/remoteEntry.js"></script>
</body>
...
Enter fullscreen mode Exit fullscreen mode

Importing React component from a remote project

Now it's time to use the Counter component from the second project into the first project:

// src/bootstrap.js

import React, { useState } from 'react';
import ReactDOM from 'react-dom';

const Counter = React.lazy(() => import('app2/Counter'));

function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <h1>Hello from React component</h1>
      <React.Suspense fallback='Loading Counter...'>
        <Counter
          count={count}
          onIncrement={() => setCount(count + 1)}
          onDecrement={() => setCount(count - 1)}
        />
      </React.Suspense>
    </>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));
Enter fullscreen mode Exit fullscreen mode

We will need to lazy load the Counter component and then we can use React Suspense for loading the component with a fallback

That's it! You should be able to load the counter component from the first project

Conclusions

The possibility to load remote Webpack builds into your applications opens up a new world of possibilities for creating new Frontend architectures. It will be possible to create:

Micro Frontends

Since we can have separate bundles of JavaScript into separate projects, it gives us the possibility to have separate build processes for each application.

You will be able to have totally independent applications with the feeling of a single website. This allows big teams to break down into smaller and more efficient teams which will scale vertically from the Frontend to the Backend team.

This way we will have autonomous teams which won't depend on others in order to deliver new features

It could be represented like this:

Vertical scaling

Source Image

Design system incorporation at runtime

Currently, there are multiple ways to implement a design system at build time (npm/yarn packages, GitHub packages, Bit.dev) but this could represent an issue for some projects. Whenever you need to update some components from your design system, you will have to re-build your application and deploy it again in order to have the latest version of your design system in production.

With a design system at runtime, you will be able to get the latest version of your design system into your application without going through the build and re-deploy process of your entire application since you will get the components from a different origin and at runtime.

These two are just a few of the possibilities with Federated Modules.

Repository of the complete example

github.com/brandonvilla21/module-federation

Top comments (11)

Collapse
 
vonwao profile image
vonwao

I still don't fully understand the big deal here. Couldn't you already load JS modules dynamically before by using the import statement? And you could already compose the separate modules into an app by using single-spa.js.org/

It seems the main advantage remaining would be using dynamic modules for SSR, but from what I heard Next.JS doesn't support module fedaration. :/

So what is actually the big idea? Looking at the webpack config above I'm concerned this new architecture is just going complicate things and also lock-me-in to webpack.

Collapse
 
scriptedalchemy profile image
Zack Jackson

Next supports MF, with some workarounds that I created. Next 10 will support MF as a flagship feature.

Single SPA doesn't make the JS payload smaller, it lets you mount many apps and bus data between them. Module federation is pretty much dynamic imports between separate webpack builds that are deployed independently. Single SPA also relies on system.js to share code. MF can support multiple versions if needed and has intelligent semantic versioning at runtime.

As for support, I've already released partial support for rollup and parcel will be adhering to our API interface. All three bundlers should be compatible with each other since we are working to make this interface universal. No lock-in.

Collapse
 
arshita04 profile image
Arshita Narayan

You have mentioned,that the support for rollup and parcel were released. Couldn't find the example which shows the support for parcel anywhere.
Also, how is module federation different from single spa.

Collapse
 
brandonvilla21 profile image
Brandon Villa

Hi vonwao. Actually the new thing here is not dynamically loading, as you said you could do this by using import() syntax. The new feature here is that with only a single plugin you have the capability to separate your JS code into different projects and work with them at build time, I don't think there will be much change in your Webpack config if it's only a matter of adding a Plugin pretty much :)

Not entirely sure if single-spa works at build time or runtime, which could be a big difference, but I see they are already experimenting with using Module Federation, the only downside I see for single-spa is that the setup looks heavier

People from Webpack are working with Next.JS to bring this feature btw: github.com/module-federation/modul...

Collapse
 
alexvukov profile image
alex-vukov • Edited

Thank you for this article! It was very useful since I am in the process of making use of federated modules in a large corporate MFE solution. There is one small problem in this new functionality though and that is cachebusting the remoteEntry scripts. In order to invalidate the cache for a remoteEntry script each time it changes you have to add a content dependent hash to its name. But then you have to somehow know the new name in order to import it into the shell application. This creates the need for an extra request to get the new name before loading the remote container which is inconvenient.

Collapse
 
joscluis profile image
JoscLuis

Obtengo un error al compilar la aplicación: npm run build
error:

12 0.726

12 0.726 > container@0.1.0 build /app

12 0.726 > react-scripts build

12 0.726

12 2.108 Creating an optimized production build...

12 8.336 Failed to compile.

12 8.336

12 8.337 Module not found: Error: Can't resolve 'app2/App' in '/app/src/modules/traking'

12 8.337

12 8.337

12 8.503 npm ERR! code ELIFECYCLE

12 8.503 npm ERR! errno 1

12 8.506 npm ERR! container@0.1.0 build: react-scripts build

12 8.506 npm ERR! Exit status 1

12 8.506 npm ERR!

12 8.507 npm ERR! Failed at the container@0.1.0 build script.

12 8.507 npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

12 8.514

12 8.514 npm ERR! A complete log of this run can be found in:

12 8.514 npm ERR! /root/.npm/_logs/2021-12-21T16_42_43_474Z-debug.log

Collapse
 
fburner profile image
FBurner

I have problems to understand if this is correct solution, the purpose of microfrontends would be not to be exposed to other but be selfcontained systems.

So the only problem that should have been solved is the loading of depencies and pushing the bundle to some application register.

With this it looks like you just build a distributed monolith?

Collapse
 
norbertb29 profile image
Norbert Bartos

Thanks for this post Brandon!

This is exactly what our current project needs to fulfill the requirements. We will have some (1-3) SPAs with N different modules everything on the same stack (React based) with separated CI/CD pipeline. These modules and SPAs will be developed by different teams and we will need to integrate these modules into the given SPA.

I am just thinking about this new feature and I am not sure if I correctly understood it. I don't really see the differences between this plugin and my concept. For example we can integrate separately built and deployed app modules into the "parent" SPA with the current tools already (at least we can do it by authoring webpack libraries and loading them into the SPA so we can control the module lifecycle by using React DOM to mount/unmount it into the given SPA's DOM container element).

What will be the benefits of using Module Federation?

Is this what solves the described problem with more elegant way? I see that this could optimize the bundle sizes by sharing the common dependencies. In the example you are lazy importing the exposed Counter component directly, but I assume type inference will not work, because it's not in the same module like we would do in monorepo setup. Am I missing something?

Collapse
 
deexter profile image
deexter

What if my MF accepts some props?
Is there possibility to expose types for typescript?
Or is this bad practise and MF has to be without props?

Collapse
 
plsfix profile image
PLSFIX

Nice article, thx!
Is it correct both apps have the same port?

publicPath: "http://localhost:3000/",

Collapse
 
brandonvilla21 profile image
Brandon Villa

Thanks for your comment, actually it should be a different port. Good catch! I'll update the post :)