Skip to main content

A Beginner’s Guide to the Micro Frontend Architecture

By Chris Laughlin

JavaScript

Share:

Free JavaScript Book!

Write powerful, clean and maintainable JavaScript.

RRP $11.95

Gone are the days of a single web page for your cat or dog. Modern web development delivers rich user experiences that span the gambit of user flows and interactions. Building, maintaining, deploying, and delivering these experiences requires large-scale developer teams and complex deployment systems.

The Current State of Web Applications

The most common pattern used for modern web applications is the single-page application (SPA). The core principle of an SPA is building a single web application that is delivered to the user. The SPA works by rewriting the page contents based on user interactions or data changes. An SPA will usually contain a router to handle page navigation and deep linking and can be made up of multiple components — such as a shopping basket or product list.

The typical SPA application flow follows standard steps:

  • the user visits the web application
  • the browser requests the JavaScript and CSS
  • the JavaScript application starts and adds the initial content to the browser document
  • the user interacts with the application — such as clicking a navigation link or adding a product to the basket
  • the application rewrites parts of the browser document to reflect the changes

In most cases, a JavaScript framework is used to achieve the above. Frameworks like React, Vue, or Angular have patterns and best practices to help build an SPA. React, as an example, is a very intuitive framework using JSX to render content based on user and data change. Let’s look at a basic example below:

//App.js
import React from "react";
import "./styles.css";

const App = () => {
 return (
   <div className="App">
     <h1>Hello I'm a SPA 👋</h1>
   </div>
 );
}

export default App;

This is our basic application. It renders a simple view:

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

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
 <React.StrictMode>
   <App />
 </React.StrictMode>,
 rootElement
);

Next, we start the application by rendering the React application into the browser DOM. This is just the foundation of the SPA. From here, we could add more features such as routing and shared components.

SPAs are the staple of modern development, but they aren’t perfect. An SPA comes with many disadvantages.

One of them is the loss of search engine optimization, as the application is not rendered until the user views it in the browser. Google’s web crawler will try to render the page but not fully render the application, and you’ll lose many of the keywords you need to climb the search ranks.

Framework complexity is another disadvantage. As mentioned before, there are many frameworks that can provide the SPA experience and allow you to build a solid SPA, but each targets different needs, and knowing which to adopt can be hard.

Browser performance can also be an issue. Because the SPA does all the rendering and processing of the user interactions, it can have a knock-on effect depending on the user’s configuration. Not all users will be running your application in a modern browser on a high-speed connection. Keeping bundle size down and reducing processing on the client as much as possible is needed to have a smooth user experience.

All of the above leads to the ultimate issue, which is scale. Trying to build a complex application that can fit all your user’s needs requires multiple developers. Working on an SPA can result in many people working on the same code trying to make changes and causing conflicts.

So what’s the solution to all of these problems? Micro frontends!

What is a Micro frontend?

A micro frontend is an architecture pattern for building a scalable web application that grows with your development team and allows you to scale user interactions. We can relate this to our existing SPAs by saying it’s a sliced-up version of our SPA. This version still looks and feels like an SPA to the user, but under the hood it dynamically loads parts of the application based on the user’s flow.

To explain this more, let’s take the example of a pizza shop application. The core features include choosing a pizza and being able to add it to your basket and check out. Below is a mock-up of our SPA version of the application.

Mock-up of a pizza shop SPA

Let’s turn this into a micro frontend by thinking about the different parts of the application that can be sliced up. We can think of this in the same way we would when breaking down what components are needed to create our application.

SPA broken into micro frontends

All micro frontends start with a host container. This is the main application that holds all the parts together. This will be the main JavaScript file that gets sent to the user when visiting the application. Then we move on to the actual micro frontends — the product list, and the basket frontend. These can be locally separated from the main host and delivered as a micro frontend.

Let’s dig into “locally separated from the main host” more. When we think of the traditional SPA, in most cases you build one JavaScript file and send this to the user. With a micro frontend, we only send the host code to the user, and depending on the user flow we make network calls to fetch the additional code for the rest of the application. The code can be stored on different servers from the starting host and can be updated at any time. This leads to more productive development teams.

How to Build a Micro frontend?

There are multiple ways to build a micro frontend. For this example, we’re going to use webpack. Webpack 5 released module federation as a core feature. This allows you to import remote webpack builds into your application, resulting in an easy-to-build-and-maintain pattern for micro frontends.

The full working webpack micro frontend application can be found here.

Home Container

First, we need to create a container that will be the home of the application. This can be a very basic skeleton of the application or could be a container with a menu component and some basic UI before the user interacts with the product. Using webpack, we can import the ModuleFederation plugin and configure the container and any micro frontends:

// packages/home/webpack.config.js

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  ...

  plugins: [
    new ModuleFederationPlugin({
      name: "home",
      library: { type: "var", name: "home" },
      filename: "remoteEntry.js",
      remotes: {
        "mf-products": "products",
        "mf-basket": "basket",
      },
      exposes: {},
      shared: require("./package.json").dependencies,
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
  ],
};

Note: you can view the webpack.config.js file on GitHub here.

Here, we give the module the name “home”, as this is the container that holds all the frontends. Then we provide library details, as the container can also be a micro frontend so we declare details about it — such as its type, which in this case is a var. The type defines which webpack module type it is. var declares that the module is an ES2015 complied module.

We then have the products and basket modules set as remotes. These will later be used when importing and using the components. The name we give the modules will be used when importing them into the application (“mf-products” and “mf-basket”).

After we configure the modules, we can add script tags to the home’s main index.html file, which will point to the hosted modules. In our case, this is all running on localhost, but in production this could be on a web server or an Amazon S3 bucket.

<!-- packages/home/src/index.html -->

<script src="http://localhost:8081/remoteEntry.js"></script> //product list
<script src="http://localhost:8082/remoteEntry.js"></script> //basket

Note: you can view the index.html file on GitHub here.

The last part for the home container is to import and use the modules. For our example, the modules are React components, so we can import them using React.lazy and use them just like we would with any react components.

By using React.lazy we can import the components, but the underlying code will only get fetched when the components are rendered. This means that we can import the components even if they’re not used by the user and conditionally render them after the fact. Let’s take a look at how we use the components in action:

// packages/home/src/src/App.jsx

const Products = React.lazy(() => import("mf-nav/Products"));
const Basket = React.lazy(() => import("mf-basket/Basket"));

Note: you can view the App.jsx file on GitHub here.

The key difference here from standard component usage is React.lazy. This is a built-in React function that handles async loading of code. As we’ve used React.lazy to fetch the code when it’s used, we need to wrap the component in a Suspense component. This does two things: it triggers the fetch of the component code, and renders a loading component. Other than the Suspense component and the fallback component, we can use our micro frontend module just like any other React component.

Product and Basket

After we configure the home container, we need to set up the product and basket modules. These follow a similar pattern to the home container. First, we need to import the webpack ModuleFederation plugin, like we did in the home container’s webpack config. Then we configure the module settings:

// packages/basket/webpack.config.js

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  ...

  plugins: [
      new ModuleFederationPlugin({
        name: 'basket',
        library: {
          type: 'var', name: 'basket'
        },
        filename: 'remoteEntry.js',
        exposes: {
          './Basket': './src/Basket'
        },
        shared: require('./package.json').dependencies
      })
  ],
};

Note: you can view the webpack.config.js file on GitHub here.

We provide the module a name that will be products or basket and the library details, then a fileName — in this case remote entry. This is a standard for webpack, but it could be anything you want — such as a product code name or module name. This will be the file that webpack generates and that will be hosted for the home container to reference. Using the fileName remoteEntry, the full URL to the module would be http://myserver.com/remoteEntry.js. We then define the expose option. This defines what the module exports. In our case it’s just the Basket or Products file, which is our component. However, this could be multiple components or different resources.

And finally, back in the home container, this is how you can use these components:

// packages/home/src/src/App.jsx

<div className="app-content">
  <section>
    <React.Suspense fallback={<div>....loading product list</div>}>
      <ProductList
        onBuyItem={onBuyItem}
      />
    </React.Suspense>
  </section>
  <section>
    {
      selected.length > 0 &&
      <React.Suspense fallback={<div>....loading basket</div>}>
        <Basket
          items={selected}
          onClear={() => setSelected([])}
        />
      </React.Suspense>
    }
  </section>
</div>

Note: you can view the Product and Basket usage file on GitHub here.

Dependencies

We haven’t yet talked about dependencies. If you noticed from the above code examples, each webpack module config has a shared configuration option. This tells webpack what Node modules should be shared across the micro frontends. This can be very useful for reducing duplication on the final application. For example, if the basket and home container both use styled components, we don’t want to load in two versions of styled components.

You can configure the shared option in two ways. The first way is as a list of the known shared Node modules that you know you want to share. The other option is to feed in the modules dependency list from its own package JSON file. This will share all dependencies, and at runtime webpack will determine which it needs. For example, when the Basket gets imported, webpack will be able to check what it needs, and if its dependencies have been shared. If the basket uses Lodash but the home doesn’t, it will fetch the Lodash dependency from the baskets module. If the home does already have Lodash, it won’t be loaded.

Disadvantages

This all sounds great — almost too good to be true. In some cases it’s the perfect solution. In others, it can cause more overhead than it’s worth. Even though a micro frontend pattern can enable teams to work better together and quickly advance on parts of the application without being slowed down by cumbersome deployment pipelines and messy Git merges and code reviews, there are some disadvantages:

  • Duplicated dependency logic. As mentioned in the dependencies section, webpack can handle shared Node modules for us. But what happens when one team is using Lodash for its functional logic and another is using Ramda? We’re now shipping two functional programming libraries to achieve the same result.
  • Complexity in design, deployment and testing. Now that our application dynamically loads content, it can be harder to have a full picture of the full application. Making sure to keep track of all the micro frontends is a task in itself. Deployments can become more risky, as you’re not 100% sure what’s being loaded into the application at run time. This leads into harder testing. Each frontend can be tested in isolation, but getting a full, real-world user test is needed to make sure the application works for the end user.
  • Standards. Now that the application is broken into smaller parts, it can be hard to keep all developers working off the same standards. Some teams might advance more than others and either improve or diminish the code quality. Keeping everyone on the same page is important to delivering a high-quality user experience.
  • Maturity: micro frontends are not a new concept and have been achieved before using iframes and custom frameworks. However, webpack has only recently introduced this concept as part of webpack 5. It’s still new to the world of webpack bundling, and there’s a lot of work to build out standards and discover bugs with this pattern. There’s still a lot of work to be done to make this a strong, production-ready pattern that can be easily used by teams working with webpack.

Conclusion

So we’ve learned how to build a React application using webpack module federation and how we can share dependencies across the micro frontends. This pattern of building an application is perfect for teams to break an application into smaller parts to allow faster growth and advancement compared to the traditional SPA application, which would have a slow deployment and release process. Obviously this isn’t a silver bullet that can be applied to all use cases, but it’s something to consider when building your next application. As everything is still very new, I’d advise that you adopt micro frontends early to get in at the ground level, as it’s easier to move from a micro frontend pattern to a standard SPA than the other way around.

Application developer based in Belfast, Northern Ireland. Focused on front end development especially JavaScript. Been working in software development since 2010 and still learning and sharing everyday.

New books out now!

Get practical advice to start your career in programming!


Master complex transitions, transformations and animations in CSS!

Latest Remote Jobs