How to build Micro Frontends with React?
EDIT: I have now implemented the approach presented below as an open-source project called tiny-frontend 🐰. If you’re interested to use this architecture in your projects after reading this article, feel free to have a try!
Front End at Cazoo
Cazoo is a fast growing startup that is transforming the way people buy used cars. We offer a fully online car buying experience for customers in the UK. Customers can find a car on our site, buy it in just a few minutes, and get it delivered to their door.
We practise Domain Driven Design. Teams are cross functional and own their business domain. We want each team to be experts in their own business domain, and drive the customer experience for it.
For example the Search and Browse team owns the home page, search page and vehicle detail page, and the Checkout team owns every page of the checkout flow.
Our front end follows a microsite architecture. Teams share a gateway and all own specific routes on the cazoo.co.uk domain. This architecture lets teams deploy to production independently and reduces dependencies.
An ownership problem
While this model works very well for most cases, there are still some cases where page level splitting is not granular enough.
For example:
- What if the Consumer Finance team wants to show a finance calculator on the vehicle detail page that is owned by Search and Browse?
- What if the Logistics team wants to let users reschedule their delivery when they are logged in their user account?
For a lot of those cases, we are currently using APIs for interoperability. As an example, let’s look at the finance calculator use case:
- Finance provides an API that can return quotes.
- The Search and Browse codebase contains a FinanceCalculator component that calls this API and displays quotes to the user.
This works great, however now we have an ownership problem. Either:
- Search and Browse owns that front end, and we are leaking Consumer Finance business concerns to that team.
- Consumer Finance owns that front end, but has a deployment dependency on Search and Browse and cannot deploy changes independently to that component.
What do we want?
Ideally we would want the Consumer Finance team to:
- Own both the front end and the API of that calculator
- Deploy that component independently
- Be able to test that component in isolation
We would want the Search and Browse team to:
- Consume that component with minimum effort
- Not have to redeploy when the finance calculator changes
- Make sure their code behaves correctly in response to the contract with that component
Micro Frontends to the rescue!
For those screaming at your screen right now, you’re right, micro frontends might help here!
The main goal of micro frontends is to get code deployed separately running in a cohesive fashion on a single page. Sounds exactly like what we need!
Initial experiment: web components
The micro frontend philosophy pushes for solutions that are tech stack agnostic. This means each team should be able to choose their own stack.
Following that, we initially experimented with web components. However, we encountered a few hurdles:
- Web Components are not supported in IE 11, and polyfills for them are quite heavy.
- Making React work in a Web Component is tricky due to the way React handles DOM events.
- Wrapping Styled Components in a Web Component is also complicated due to shadow DOM.
Another downside of that solution is that we couldn’t share dependencies between components. This means that clients might have to download multiple versions of React and Styled Components which are ubiquitous in our codebases.
React Based Micro Frontend
The good thing about Cazoo having grown so fast is that our stack is very homogenous. We use React and Styled Components for all front ends. So why not just use React as the glue between those shared components?
Let’s say the Logistics team wants to let users reschedule their delivery in My Account:
- The Logistics Team would develop a
RescheduleHandover
component and publish it as a JS bundle - The My Account team would pull the latest JS bundle at runtime and use the
RescheduleHandover
component - The TypeScript type of the component would act as the contract between teams
Well, that’s exactly what we have done!
Lazy React
First, can React load a component dynamically at runtime? Yes! React supports asynchronously loading a component using React.Lazy and Suspense.
This is usually used for code splitting:
In this example import
references a local component. At build time, the bundler (webpack for example) will create a separate bundle containing OtherComponent
. At runtime, when MyComponent
gets loaded, the bundle containing OtherComponent
will be fetched. While that happens the user will see the Suspense
fallback.
So React already has the ability to have components loaded from the network at runtime! However, the glue is done at build time by webpack. Can we change that?
It turns out, React.lazy()
statement is not magic ✨. All it expects is a function returning a Promise of an ES Module with the component as a default export. This means this is valid code:
Great! Now we just need to find a way to get RescheduleHandover
from the network at runtime!
Sidenote: React.lazy/Suspense is not strictly needed for this to work, check out Annex 2 for some example code that does not use React.lazy 👍.
Loading a React Component from the network
This is the flow we have decided to go for:
- The My Account app requests an API endpoint with the name and version of the bundle it wants.
- The API looks in a table for the latest JS file deployed for that bundle version.
- It then returns the public path to that file to the client.
- The consumer creates a script tag to load that JS file.
- Once the script is loaded, the consumer expects to find
RescheduleHandover
exposed on the global scope.
We decided to use webpack to bundle RescheduleHandover
. This lets us:
- Consume the local version of React and Styled Components as webpack externals.
- Expose the
RescheduleHandover
on global for the consumer to use
This works great:
For the Logistics team providing RescheduleHandover
:
- ✅ They can push non-breaking changes by pushing a new JS bundle to the latest version.
- ✅ For breaking changes, they increase the version they push to.
For the My Account team consuming RescheduleHandover
:
- ✅ Users will get the latest iteration of the component when they reload the page.
- ✅ The team needs to opt-in to breaking changes by increasing the version they pull the JS bundle for.
However, this comes with the following problems:
- ❌ The TypeScript type for
RescheduleHandover
needs to be duplicated between Logistics and My Account. - ❌
RescheduleHandover
required React version or Styled Components version might not be compatible with those provided by My Account at runtime. - ❌ All the consumers of
RescheduleHandover
need to duplicate the work of consuming it from the network. - ❌ My Account needs to know where to find the component on the global scope, and which webpack externals to expose.
Making the consumer’s life easier
Looking at the method that simply returned a React component to React.lazy
, it felt like a much nicer interface.
My Account doesn't really care about how getting the RescheduleHandover
happens. All it wants is:
- Opt-in to a version of
RescheduleHandover
. - Know the TypeScript type of
RescheduleHandover
in that version. - Know which dependencies it needs to have to consume that version of
RescheduleHandover
.
To solve this we decided to go with a contract, taking the shape of an npm package published by the team providing the component.
First, the Logistics team publishes a @cazoo-uk/reschedule-handover
package that:
- Exposes a
getRescheduleHandoverAsync
function - Exposes the TypeScript type for
RescheduleHandover
- Declares the version of React and Styled Components it expects as peer dependencies.
Then, My Account just opts in to a version of that contract, and makes sure it matches the peer dependencies version:
At runtime, the getRescheduleHandoverAsync
function exposed by @cazoo-uk/reschedule-handover
will always return the latest deployed component for that version.
The great thing with this approach is that we get the best of both worlds:
- Automatic enrolment for non-breaking changes
- Voluntary upgrade to breaking changes
Sharing the fun
One last problem is that the Logistics team now owns how to publish and consume new versions of their component at runtime. As more and more teams want to publish micro frontends we don’t want to have that logic duplicated everywhere.
Enters @cazoo-uk/shared-libraries
!
This package abstracts away how to publish and consume ES modules at runtime. It provides:
getWebpackConfigForSharedLibraryBundle
: a script to generate output and externals in the webpack bundle configurationpublishLibrary
: a script to publish a new bundle for a given remote library versiongetRemoteSharedLibrary
: a function to consume a remote library at runtime
Publishing the Micro Frontend: Logistics
First, we have a repository responsible for publishing the RescheduleHandover
micro frontend component and owned by the Logistics team.
This repository contains two folders:
- app: The source of the component that will be bundled and deployed
- contract: The npm package for consumers
app
This folder contains:
- The source and tests of the
RescheduleHandover
component - A simple react app to run the component locally in isolation
- A
webpack.config.bundle.js
file used to build the JS bundle - A script used to deploy the bundle
Webpack configuration
This configuration is used to build the bundle containing the component.
The dependencies array is used to generate the webpack externals. These will tell webpack where to look for React and Styled Components on the global scope when the bundle is loaded in the consumer app.
At runtime, the getRemoteSharedLibrary
function, also provided by shared-libraries
, will get those dependencies from the consumer and set them on the global scope.
Deploy script
This script runs on our CI to push new releases of the JS bundle:
contract
This is just a small npm package bundled using rollup and published on our private npm repository.
It exposes the RescheduleHandover
TypeScript type and a getRescheduleHandoverAsync
function for consumers:
You may have noticed the dependencies
parameter. React and Styled Components are marked as peer dependencies of the npm package. We also tell Rollup to not bundle them by marking them as externals.
This means the consumer bundler will be providing its own versions of those dependencies at build time.
As mentioned in the webpack configuration section, they will then be exposed at runtime by getRemoteSharedLibraries
before loading the remote JS bundle.
For more details, you can have a look at the code in the Annex at the bottom of this article.
Consuming the Micro Frontend: My Account
How does this look from the consumer’s point of view?
My Account application depends on the @cazoo-uk/reschedule-handover
package. Then in a reschedule-component.tsx
file it simply calls getRescheduleHandoverAsync
:
RescheduleHandoverComponent
can then be used like any regular React component.
That’s it 🎉!
Conclusion
Let’s go back to what we wanted:
For the team providing the component we wanted:
- ✅ Own both the front end and the API
- ✅ Deploy that component independently
- Possible as long as there are no breaking changes to the contract (TypeScript type or peer dependencies) - ✅ Be able to test the feature in isolation
For the team consuming the component we wanted:
- ✅ Consume the component with minimum effort
- Add an npm dependency, call a function and wrap the result inSuspense
- ✅ Not have to redeploy when the consumed component changes
- They do not have to redeploy for non-breaking changes.
- They need to opt-in to breaking changes in the TypeScript type and peer dependencies. - ✅ Make sure their code behaves correctly in response to the contract with that component.
- They can write unit tests against a mock following the TypeScript type provided by the npm package they consume.
✅ We also managed to do this while sharing some dependencies between the provider and the consumer!
We are currently only using this approach for the scheduling component, but we are looking forward to experimenting on other use cases.
Thank you for reading this article, we’re curious to hear your thoughts in the comments!
If you enjoy these kinds of challenges, and you’re interested in joining us, Cazoo is hiring!
EDIT: If you want to follow this approach on your projects, I made an open-source implementation of this architecture called tiny-frontend 🐰, along with documentation on how to deploy everything you need.
Annex 1: Shared Libraries code
So what do those magic functions from Shared Libraries do? Well not that much. I’ve omitted error handling for simplicity:
Annex 2: Loading a remote component without React.lazy
If you’re using Next.js then you’ll quickly notice that Suspense is not supported on SSR.
Instead of having code to handle not running Suspense
on SSR you can simply load your remote component in a useEffect
block: