DEV Community

loading...

Let's Build Micro Frontends with NextJS and Module Federation!

hamatoyogi profile image Yoav Ganbar Originally published at Medium ・12 min read

That headline is a mouth-full, I know!

In the past several years I have been working on distributed and multiple teams as well as being a pretty early adopter of NextJS (since around V2.0!) in production. I've worked on micro frontends with shared npm packages while trying to orchestrate one cohesive user experience.

It was and is hard.

That’s why I have been closely following the latest developments in the field, and since I’ve heard about Webpack 5 Module Federation, I was curious how and when it would work with an amazing framework such as NextJS.

I guess the title and all those buzzwords need a little breakdown and explaining before we get down to business, so… here we go!

What are Micro Front Ends?

Micro Front Ends are like microservices for the front end. Think about it as an encapsulated, self-contained piece of code or component that can be consumed anywhere. To quote micro-frontends.org:

"The idea behind Micro Frontends is to think about a website or web app as a composition of features which are owned by independent teams. Each team has a distinct area of business or mission it cares about and specializes in. A team is cross functional and develops its features end-to-end, from database to user interface."

https://microfrontends.com/img/deployment.png

Source: https://micro-frontends.org/

You can read more about this concept in the provided link above or here. The key core concepts to remember:

  • Technology agnostic
  • Isolated Team Code
  • Build a resilient site / App

There are several frameworks and approaches to implement this architecture, but this is not the subject of this post. I will be focusing on sharing code.

What's Module Federation?

Technically speaking, Module Federation is a Webpack v5 feature which allows separate (Webpack) builds to form a single application. However, it's much more than that...

To paraphrase Zack Jackson (don't remember where I heard it or saw it), one of the creators of Module Federation:

It is a distributed application architecture. Independently deployed bundles working as a monolith at runtime.

So, in a few bullet points:

  • It’s a type of JavaScript architecture.
  • It allows a JavaScript application to dynamically load code from another application
  • It allows haring dependencies - if an application consuming a federated module does not have a dependency needed by the federated code — Webpack will download the missing dependency from that federated build origin.
  • Orchestrated at runtime not build time - no need for servers - universal

Module Federation is a tool based approach to implementing micro front-end architecture.

It is important not to confuse Module Federation with Webpack [DllPlugin](https://webpack.js.org/plugins/dll-plugin/) which is a tool mostly focused on improving build time performance. It can be used to build apps that depend on DLLs (Dynamic Link Library), but this can cause deploy delays, there is the extra infrastructure for compile-time dependency, it needs to rebuild when parts change (which causes deploy delays), and it is highly dependent on external code with no fail-safe. In summary, DLLs don't scale with multiple applications and require a lot of manual work for sharing.

Module Federation, on the other hand, is highly flexible while allowing only less deploy delay due to needing only the shared code and app to be built. It is similar to Apollo GraphQL federation but applied to JavaScript modules - browser and Node.js.

Some terminology that is useful to know when talking about Module Federation:

  • Host: A Webpack build that is initialized first during a page load
  • Remote: Another Webpack build, where part of it is being consumed by a “host”
  • Bidirectional-hosts: can consume and be consumed
  • Omnidirectional-hosts: A host that behaves like a remote & host at the same time

I could blabber on a lot more about this, but if you want to learn more you can visit the official website, you can get the "Practical Module Federation" book, or you can check out the resources section.

What's NextJS?

If you're not familiar with the frontend/React ecosystem or you have been living under a rock, NextJS is a React framework for building hybrid static and server-side rendered React application.

Basically, it takes off a lot of the hassle of configuring, tinkering, and retrofitting what it takes to get a React application (or website) to Production.

It has a large variety of features out of the box that just makes any web developer grin like a giddy school girl.

To name a few key features:

  • Zero configuration
  • TypeScript Support
  • File-system routing
  • Built-in serverless functions (AKA API routes)
  • Code splitting and bundling

You can also hear a little bit about what NextJS is in a talk I gave (my first one! pardon my nervousness there 😅) - keep in mind this was a year and a half ago from the time of this writing and the NextJS team have added a whole lot of features and optimizations since.

For the sake of this post, it is important to remember that frameworks have limitations and in this tutorial, we are fighting some of the limitations NextJS has. The team behind NextJS has made incredible strides in a short period of time. However, to be able to use Module Federation we will need to work around some key aspects, such as no Webpack v5 support (yet) and the framework is not fully async.

What are we going to build?

We're going to build 2 Next JS apps:

  1. Remote App (App 1)- will expose a React component and 2 functions
  2. Consumer (App 2) - will consume code/components from the first app.

You can also watch this great video by Jack Herr if you're more into that.

If you want to skip all of this and see all of the code, here's a link to the repo.

So.. after that's out of our way...

Let's do it!

https://gph.is/2Nek1o9

First Steps:

  • Create a folder to hold both apps.
  • To kick start the first app go into the created folder and run :
npx create-next-app app1 
Enter fullscreen mode Exit fullscreen mode
  • Kick start the second (notice that this time its app2):
npx create-next-app app2 
Enter fullscreen mode Exit fullscreen mode

Ok, now we should have 2 apps with NextJS with a version that should be ^9.5.6.

If you want to stop and try to run them to see they work, just go to their folders and start them off with:

yarn run dev
Enter fullscreen mode Exit fullscreen mode

Now, in order to use Module Federation, we need Webpack v5, but alas, at the time of this writing Next's latest version still runs Webpack 4. 😢

But don't panic yet! Luckily for us, our friend Zack has us covered with a little nifty package for this transition period called @module-federation/nextjs-mf!

Setting up our remote app:

Step 1

Go into app1 and run:

yarn add @module-federation/nextjs-mf
Enter fullscreen mode Exit fullscreen mode

Step 2

In order to use Webpack 5 with our Next apps we're going to need to add resolutions to our package.json:

"resolutions": {
    "webpack": "5.1.3"
  },
Enter fullscreen mode Exit fullscreen mode

What this does is tell our package manager to use this specific version of Webpack we want to use. But because we've used create-next-app to bootstrap our app, we now need to clean up our node_modules:

// in the same folder for app1 delete node_modules:
rm -rf node_modules

// re-install all of our pacakges, but this time Webpack 5 should be installed:
yarn install
Enter fullscreen mode Exit fullscreen mode

Our boilerplate code is almost ready. What we are missing at this point are the modules we would want to expose to our consumer app.

Let's add some.

Step 3

First we'll create just a simple Nav component:

import * as React from 'react';

const Nav = () => {
  return (
    <nav
      style={{
        background: 'cadetblue',
        width: '100%',
        height: '100px',
        color: 'white',
        textAlign: 'center',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        fontSize: '24px',
      }}>
      Nav
    </nav>
  );
};

export default Nav;
Enter fullscreen mode Exit fullscreen mode

Now just to make sure it's working we'll add it to our index.js page and see it render:

import Nav from '../components/nav'

export default function Home() {
  return (
    <div className={styles.container}>
            {/* JSX created by create-next-app */}
      <main className={styles.main}>
        <Nav />
            {/* mroe JSX created by create-next-app */}
            </main>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

If we run yarn dev in app1 folder and go to localhost:3000 we should see something like this:

app1-start

Step 4

We'll add two functions to export as well:

// utils/add.js

const add = (x,y) => {
    return x + y;
}

export default add

// utils/multiplyByTwo.js

function multiplyByTwo(x) {
    return x *  2;
}

export default multiplyByTwo
Enter fullscreen mode Exit fullscreen mode

Step 5

After these steps we should be able to use configure our Module Federation Webpack plugin. So, we need to create a next.config.js file in the root folder and add this:

const {
  withModuleFederation,
  MergeRuntime,
} = require('@module-federation/nextjs-mf');
const path = require('path');

module.exports = {
  webpack: (config, options) => {
    const { buildId, dev, isServer, defaultLoaders, webpack } = options;
    const mfConf = {
      name: 'app1',
      library: { type: config.output.libraryTarget, name: 'app1' },
      filename: 'static/runtime/remoteEntry.js',
      // This is where we configure the remotes we want to consume.
      // We will be using this in App 2.
      remotes: {},
      // as the name suggests, this is what we are going to expose
      exposes: {
        './nav': './components/nav',
        './add': './utils/add',
        './multiplyByTwo': './utils/multiplyByTwo',
      },
      // over here we can put a list of modules we would like to share
      shared: [],
    };

    // Configures ModuleFederation and other Webpack properties
    withModuleFederation(config, options, mfConf);

    config.plugins.push(new MergeRuntime());

    if (!isServer) {
      config.output.publicPath = 'http://localhost:3000/_next/';
    }

    return config;
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 6

Next, we need to add pages/_document.js:

import Document, { Html, Head, Main, NextScript } from "next/document";
import { patchSharing } from "@module-federation/nextjs-mf";

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    return (
      <Html>
                {/* This is what allows sharing to happen */}
        {patchSharing()}
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;
Enter fullscreen mode Exit fullscreen mode

Side note :

for easing this process it is possible to install @module-federation/nextjs-mf globally (yarn global add @module-federation/nextjs-mf) and from app2 folder run:

nextjs-mf upgrade -p 3001
Enter fullscreen mode Exit fullscreen mode

This will setup up your package.json , _document.js, and next.config.js from the exposing app set up steps (2, 5, 6) as well as set up the running script for this app to run on PORT:3001 to avoid port clashes.

However, the caveat of this method (at the time of this writing) is that for some reason this changes our NextJS version and nexjs-mf package version to older ones (in package.json):

{
  "name": "app2",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3001",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^9.5.6-canary.0",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "@module-federation/nextjs-mf": "0.0.1-beta.4"
  },
  "resolutions": {
    "webpack": "5.1.3",
    "next": "9.5.5"
  }
}
Enter fullscreen mode Exit fullscreen mode

Just be aware if you use this method.


Setting up our consumer app:

If you've opted out of using the above method, make sure you're package.json looks like this:

{
  "name": "app2",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3001",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "10.0.2",
    "react": "17.0.1",
    "react-dom": "17.0.1",
    "@module-federation/nextjs-mf": "0.0.2"
  },
  "resolutions": {
    "webpack": "5.1.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we need to repeat the same steps as in Step1 and Step2 from the exposing app (add resolutions, remove node_modules and reinstall), just make sure you're targeting app2 folder.

Next, create your next.config.js:

const {
  withModuleFederation,
  MergeRuntime,
} = require('@module-federation/nextjs-mf');
const path = require('path');

module.exports = {
  webpack: (config, options) => {
    const { buildId, dev, isServer, defaultLoaders, webpack } = options;
    const mfConf = {
      name: 'app2',
      library: { type: config.output.libraryTarget, name: 'app2' },
      filename: 'static/runtime/remoteEntry.js',
      // this is where we define what and where we're going to consume our modules.
      // note that this is only for local development and is relative to where the remote
      // app is in you folder structure.
      remotes: {
        // this defines our remote app name space, so we will be able to
        // import from 'app1'
        app1: isServer
          ? path.resolve(
              __dirname,
              '../app1/.next/server/static/runtime/remoteEntry.js'
            )
          : 'app1', // for client, treat it as a global
      },
      exposes: {},
      shared: [],
    };

    // Configures ModuleFederation and other Webpack properties
    withModuleFederation(config, options, mfConf);

    config.plugins.push(new MergeRuntime());

    if (!isServer) {
      config.output.publicPath = 'http://localhost:3001/_next/';
    }

    return config;
  },
};
Enter fullscreen mode Exit fullscreen mode

Then add _document.js:

import Document, { Html, Head, Main, NextScript } from 'next/document';
import { patchSharing } from '@module-federation/nextjs-mf';

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const initialProps = await Document.getInitialProps(ctx);
    return { ...initialProps };
  }

  render() {
    return (
      <Html>
        {patchSharing()}
        {/* This is where we're actually allowing app 2 to get the code from app1 */}
        <script src="http://localhost:3000/_next/static/remoteEntryMerged.js" />
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;
Enter fullscreen mode Exit fullscreen mode

Now we can start consuming modules from app1! 🎉🎉🎉

great success

Let's import those modules in our pages/index.js:

// We need to use top level await on these modules as they are async. 
// This is actually what let's module federation work with NextJS
const Nav = (await import('app1/nav')).default;
const add = (await import('app1/add')).default;
const multiplyByTwo = (await import('app1/multiplyByTwo')).default;

export default function Home() {
  return (
    <div className={styles.container}>
            {/* JSX created by create-next-app */}
      <main className={styles.main}>
        <Nav />
                <h2>
          {`Adding 2 and 3 ==>`} {add(2, 3)}
        </h2>
        <h2>
          {`Multiplying 5 by 2  ==>`} {multiplyByTwo(5)}
        </h2>
            {/* mroe JSX created by create-next-app */}
            </main>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Let's check that everything works as expected:

// run in /app1 folder, and then in /app2 floder:
yarn dev
Enter fullscreen mode Exit fullscreen mode

Go to your browser and open [localhost:3001](http://localhost:3001) (app2) and this is what you should see:

app2-share

We were able to consume a component and 2 modules from app1 inside of app2! 🚀🚀🚀

This is where some more magic comes in:

  • Go to app1/nav and change the backgroundColor property to something else like hotpink and hit save.
  • Stop app2 server and rerun it with yarn dev again

If you refresh [localhost:3001](http://localhost:3001) you should see this result:

app2-updated

https://media.makeameme.org/created/what-just-happened-391ydu.jpg

What happened here? We were able to simulate a code change in app1 that was received in app2 without making any changes to the actual code of app2!

Issues and Caveats Along The Way

When I first started off playing around with this setup I ran into an issue where I got a blank screen on the consumer app, apparently, it was due to the naming of my apps and folders. I've even opened up an issue about this in the next-mf package. In short, Don't use kebab case names and pay attention to the file paths 🤷🏽 🤦🏾.

Another important note is that exposing components and pages as modules works well, but there are issues when you try to use NextJS Link component.

Lastly, note that you cannot expose _app.js as a shared module.

Deployment

I thought it would be cool to see this project running in a production environment, so I went on and tried to deploy the two apps to 2 popular cloud hosting services:

Vercel - ****Attempted to deploy there, didn't work due to Webpack 5 resolutions and a clash in the platform. I have opened a ticket in their support system but still have yet to resolve the issue.

Netlify - As it is, Netlify only support sites to be deployed with the JAMStack architecture, so it only supports NextJS with static HTML export. When running a build locally, I was able to get both apps working while sharing modules even when using next export - the important file remoteEntryMerged.js was created in the .next build folder:

remote-file-in-directory

However after deploying with the correct environment variables in place, for some reason that file is missing from the sources:

remote-missing-source

Hopefully, I will be able to sort one of these out at some point. When and if I do, I will update. But as it seems, in order to get this sort of stack running in an actual production environment there is some tinkering to do. I do believe that if you try to just copy the build folder as it outputted locally to an S3 bucket or something similar, it should probably work.

Conclusion

In this post, we've seen how to set up and work with Module Federation and NextJS which allows us to share code and components, which in a way, is what allows micro frontends.

This is probably only a temporary solution to get this set up working until NextJS upgrades to Webpack 5.

One thing to keep in mind with Module Federation and using this type of architecture is that it comes with a slew of challenges as well. How to manage versions of federated modules is still in it's early days, only a handful of people have actually used it in production. There is a solution being worked on by Zack Jackson (and I'm helping out! 😎) called Federation Dashboard which uses "Module Federation Dashboard Plugin", but it's still in the making...

Another challenge might be shared modules sharing breaking contracts or APIs with consuming apps.

Then again, these are solvable problems, just ones that haven't been iterated enough through yet.

I am a strong believer in the technologies and architecture I've touched on in this post and I'm excited to see what the future holds!

Resources

hamatoyogi/next-mf-blogpost

Module Federation for NextJS 10

@module-federation/nextjs-mf

WP5-nextjs.md

Module Federation in Webpack 5 - Tobias Koppers

Webpack 5 Module Federation - Zack Jackson - CityJS Conf 2020

Introducing Module Federation

Discussion

pic
Editor guide