Microfrontends,JavaScript,React

Are We Microfrontends Yet?

Tower of Babel
Image by Pieter Brueghel the Elder, Public domain, via Wikimedia Commons

When a web application grows, it needs to scale! But does it have to be that hard?

I've been thinking about web applications, and what to do when you hit that point where you got to leave your beautiful tower of babel monolith behind. I hear the whispers of "microfrontends" whenever this topic comes up. It's pretty clear from teams and management what they want: they want to be able to deploy individually — be in control of their own development cycle.

Whenever I delve into this topic it all seems fairly complicated. There's a ton of options to choose from and some seem more focused on integration rather than continuous deployment.

Why'd you have to go and make things so complicated? -- Avril Lavigne

So that's what this article will be about, we'll try to leverage technology that we have in the browser to get something akin to microfrontends. If you want to jump ahead you can head straight to my example repository.

For this recipe we'll need a few ingredients to make it work. We won't be seeking full autonomy with web components, but try to leverage the fact that we're coming from a monolith with consistent technology. So this assumes that we're building our web application using the following technologies.

  • React
  • Single Page Application

To load our microfrontends we'll be using as much as possible of available browser technologies. We'll use es-modules and import maps. Import maps are (at the time of writing) only supported in Chrome, Edge and Opera, but can be shimmed using https://github.com/guybedford/es-module-shims.

ES Modules

We'll be importing our microfrontends as es-modules using the common code-splitting pattern used for improving performance. However instead of pointing towards a local dependency that is bundled we'll be pointing it to an external source that will contain our bundled component.

Before

const OtherComponent = lazy(() => import('./OtherComponent'));

After

const OtherComponent = lazy(() => 
  import('http://localhost:3001/index.js'));

This works well and fine, however we need to resolve react and react-dom to the same source code to avoid React doppelganger errors like https://reactjs.org/warnings/invalid-hook-call-warning.html. This can be done by externalizing the dependencies to a single external source, however this can cause brittleness as even a single version difference between the microfrontend and the main application would reintroduce the same errors.

Import maps

This is the missing piece to the puzzle! Import maps are akin to a package-lock file in the browser. It's part of your index.html file as a script tag. Below is an example of an import map.

<script type="importmap">
    {
      "imports": {
        "@example/micro": "http://localhost:3001/index.js",
        "react": "https://esm.sh/react",
        "react-dom": "https://esm.sh/react-dom",
        "react/jsx-runtime": "https://esm.sh/react/jsx-runtime"
      }
    }
</script>

When included the import map will resolve an import like import ... from 'react' to the value in the import map. This means that we can essentially create a peer dependency relationship for our microfrontend where we keep all the import ... from 'react' imports and let them be resolved by the main application. This can be done by simply putting react and react-dom in your bundlers external field for your main application and the microfrontend.

external: ["react", "react-dom", "react/jsx-runtime"]

In this example you see that we reference https://esm.sh as a source for react, react-dom and react/jsx-runtime and that's because to import react this way we need it to be an es-module and react is only exported as commonjs (come on, React (ノಥ益ಥ)ノ). However https://esm.sh bundles these dependencies as es-modules, so I just made it easy for myself. If you were to put this in production you'd probably introduce your own bundling process of this. But that's beyond this article.

Putting it all together

You can run the example by cloning the repository and executing the following.

pnpm build
pnpm preview

This will serve the microfrontend on http://localhost:3001 and the main application on http://localhost:3000. Just head to http://localhost:3000 and witness the magic of not knowing that a part of the application is deployed separately. You can even open /micro/src/main.tsx, make some changes, rebuild it using pnpm build --filter micro then just refresh the browser. It's working!

I'll end this segment by saying what we're all thinking: very cool.

Final thoughts

There are solutions like Webpack Federations that does something super similar. It has additional benefits such as resolving identical dependencies across "federations", which reduces the load on the client. However this requires you to use Webpack and I feel like if this solution is "good enough" then the boundary of "if you're able to build JS then you can serve JS" keeps things simple. It shouldn't matter whether you use Webpack, Rollup, esbuild or swcpack. It's all JavaScript in the end.

CSS

With this type of solution you essentially have the same restrictions on CSS as you would have with a monolithic application. Which means you'd most likely want to scope your CSS somehow, whether that be through CSS Modules, Styled Components or using a specificity strategy like with Tailwind. You'd also have to make a decision of what base styles and style resets are available for the whole application.

Development

This isn't exclusive to this solution, but you'll most likely want to set up development tools so that you can develop your microfrontend separately. My idea with using Vite is that I would be able to use it to bundle and also as a development server. However this isn't part of the example, but wouldn't be too hard to do yourself.