Deploying micro frontends with module federation and Next.js

Sam Chalela

Sam Chalela

Technical Principal

Micro frontends are an architectural pattern that has become a hot topic in recent years for many good reasons. It has helped organisations break complex UI applications into smaller, more manageable pieces, as well as enabling teams to work end-to-end across a well-defined product or business domain whilst maintaining a consistent user experience across the entire app.

One of the key benefits of this approach is that it enables teams to be autonomous, working independently on deployable UI applications, reducing the risk of accidental coupling and defining clear boundaries around delimited contexts, minimising dependencies. However, the practical implementation is not simple, and there are technical limitations that make it so complex, that sometimes the effort required just isn’t worth it. However, the release of Webpack 5 (and its module federation) has shone new light on this dark path, and in this article we’ll talk about how to use it with one of our favourite frameworks: Next.js.

The Webpack 5 documentation about module federation’s motivation says that “multiple independent builds should form a single application. These separate builds should not have dependencies on each other, so they can be developed and deployed individually.” That is exactly the definition of a micro frontend in my book. This approach allows your application to consume modules that are exposed by other applications on remote hosts. Pretty cool, huh? There is a really neat description of how this module federation works published by the authors.

The bad news

Using module federation with a “classic” React application is pretty straightforward, but when you try to use it in an application built with Next.js, things start to get more complex. Next.js is a very opinionated framework. That is usually a good thing as long as we agree with the opinions, right? The Next.js choices are pretty sensible for modern web development, and in addition to a delightful developer experience, they provide very powerful performance optimisations, built-in file system routing, server-side rendering, among other cool feature sets. The problem is that Next.js has no async boundary internally. It’s synchronous nature means that currently there is no way to wait for container negotiations to take place between runtimes, limiting the possibilities of using module federation with Next.js apps.

The good news… almost

Fortunately, there is a workaround that allows all Next.js enthusiasts to try the new technique on existing applications. One of the module federation authors also created a library as a solution to allow Next.js applications to expose and consume remote modules. The example works like a charm when you run it… locally. The moment you want to deploy each application on separate hosts, it all falls apart. The problem is that the example relies on a local disk copy of the remote module when it is rendered on the server, which defeats the purpose of separate deployments for micro frontends.

We can still make it work, but…

It’s all about tradeoffs and if the server-side rendering of your remote modules isn’t critical, there’s a way to make it work. Let’s dive in. This is one of the common reference architectures of a micro frontends application:

Independent deployable apps are loaded in a single container to orchestrate a consistent user experience

There is a “shell” or “container” that serves as the web platform for the individual micro frontends. Each application is deployed on separate hosts and can be modified without the need to rebuild the entire system. Let’s build it…

Our first micro frontend: It’s me, Mario!

First we create our first micro frontend. We start by creating an empty Next.js app and add a new component:

				
					// app1/components/mario.js
import Image from 'next/image'
import styles from '../styles/Mario.module.css'

const Mario = () => {
  return (
    <main className={styles.main}>
      <Image 
        src="<image-source>" 
        alt="Mario" 
        width={240}
        height={413}
      />
      <h1 className={styles.title}>
        G'day! I'm Mario, a microfrontend.
      </h1>
    </main>
  )
}

export default Mario
				
			

And then we hook it up to our index page.

				
					// app1/pages/index.js
import Head from 'next/head'
import Mario from '../components/mario'
import styles from '../styles/Home.module.css'


export default function Home() {
  return (
    <div className={styles.container}>
      <Mario />
    </div>
  )
}
				
			

So far, we have our standalone application ready to be deployed. Time to expose it to the world! Let’s install the nextjs-mf library and specify our module in the next.config.js file.

				
					// app1/next.config.js
const { withModuleFederation } = require("@module-federation/nextjs-mf");

module.exports = {
  ...
  webpack: (config, options) => {
    const { isServer } = options;
    const mfConf = {
      mergeRuntime: true, //experimental
      name: "app1",
      library: {
        type: config.output.libraryTarget,
        name: "app1",
      },
      filename: "static/runtime/app1RemoteEntry.js",
      remotes: {
      },
      exposes: {
        "./mario": "./components/mario",
      },
    };
    config.cache = false;
    withModuleFederation(config, options, mfConf);
    
    return config;
  },

  ...
};
				
			

Once deployed, this is what our first micro frontend looks like:

Screenshot of the Mario micro frontend

And we can find the bundle of the exposed module (line 15) deployed with the app.

Now that we have our first micro frontend, we can repeat the exercise for Mario’s fabulous brother Luigi and deploy it to a different host:

The shell

It is time to create our main SPA. In this type of architecture, the purpose of a shell is to orchestrate the experience between different micro frontends. Let’s do it.

First we create a new Next.js app and we add our remote modules to our next.config.js file:

				
					 // shell/next.config.js
const { withModuleFederation } = require("@module-federation/nextjs-mf");

module.exports = {
  ...
  webpack: (config, options) =&gt; {
    const mfConf = {
      name: "shell",
      library: {
        type: config.output.libraryTarget,
        name: "shell",
      },
      remotes: {
        app1: "app1",
        app2: "app2", 
      },
      exposes: {
      },
    };
    config.cache = false;
    withModuleFederation(config, options, mfConf);

    return config;
  },

  ...
};



				
			

Our shell needs to know where our micro frontends live. For Next.js, adding the host to the ModuleFederation plugin doesn’t work well, so we add them to the top of the _document.js page:

				
					// shell/pages/_document.js
import Document, { Html, Head, Main, NextScript } from "next/document"
class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx)
    return { ...initialProps }
  }

  render() {
    return (
      <Html>
        <script src="https://mf-app1.vercel.app/_next/static/runtime/app1RemoteEntry.js" />
        <script src="https://mf-app2.vercel.app/_next/static/runtime/app2RemoteEntry.js" />
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument
				
			

To navigate between our micro frontends, our shell is going to need a navigation component so we create one in our components folder:

				
					// shell/components/nav.js
import Link from 'next/link'
import styles from '../styles/Nav.module.css'

const Nav = () => (
  <div className={styles.nav}>
    <Link href="/mario">Load Mario</Link>
    <Link href="/">Home</Link>
    <Link href="/luigi">Load Luigi</Link>
  </div>
)

export default Nav

				
			

And then add it to our _app.js so it will render for any view in our application, including the ones that render the micro frontends.

				
					// shell/pages/_app.js
import Nav from '../components/nav'
import '../styles/globals.css'

function MyApp({ Component, pageProps }) {
  return ( 
    <>
      <Nav />
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

				
			

The final step is to create our pages. One page per micro frontend:

				
					// shell/pages/mario.js
const RemoteMario = (await import("app1/mario")).default

const App1 = () => (<RemoteMario />)

export default App1
				
			
				
					// shell/pages/luigi.js
const RemoteLuigi = (await import("app2/luigi")).default

const App2 = () => (<RemoteLuigi />)

export default App2
				
			

Here we hit a problem though. If we just try to import our remote module, the page will fail on SSR. To avoid the issue, we instead use the dynamic loading of Next.js to disable SSR (line 6) for the component:

				
					// shell/pages/mario.js
import dynamic from 'next/dynamic'

const RemoteMario = dynamic(
  () => import("app1/mario"),
  { ssr: false }
)

const App1 = () => (<RemoteMario />)

export default App1
				
			

Yay! Now we can deploy our three independent applications on separate hosts and our shell will orchestrate a single experience. You can see a live demo and the source code.

Although this solution means we lose one of the advantages of Next.js – its integrated SSR, we can still benefit from the other amazing features and optimisations. The Mario and Luigi teams can independently develop and deploy their applications without having to synchronise a release or even rebuild the shell. Being able to do independent deployments is probably the most important benefit of a micro frontend architecture. In future versions of Next, the SSR issue will be addressed (hopefully), but until then, lazy loading of our remote modules can help us start adopting module federation in Next.js applications today.

You made it to the end! Maybe you’re also keen to work with us as part of our Engineering team? We’re hiring 😉

Liked this post? Share it on…

Share on facebook
Facebook
Share on twitter
Twitter
Share on linkedin
LinkedIn

Related posts

Tech
Erin Zimmer

CSS Layout: Normal Flow

It’s worth understanding normal CSS flow, what it’s good for, and why it makes it so hard to centre things vertically. In order to do that though, there are three main concepts that we need to understand – block elements, inline elements, and line boxes.

Read More
Tech
Paula Feleggakis

The importance of learning to read code

Felienne Hermans recently gave a marvellous talk which relieved one of my lingering insecurities almost immediately – she told me that it was OK to spend time reading code, and that it was in fact a necessary precursor to being able to write code effectively.

Read More
Tech
Navin Keswani

Data Engineering Principles

I’ve recently joined Cogent as a Lead Developer and before that, I worked on Data related systems as a Data Engineer and as an Architect. I’d like to bring some of the knowledge and experience from these domains to the Cogent community and so I thought that cross posting this article (that also appears on my personal blog), would be a good way to start.

Read More