Micro-frontends with Next.js and Module Federation

Micro-frontends with Next.js and Module Federation

Server-side rendered micro-frontends solution

Imagine a typical e-commerce app with a header containing a menu, search bar, user account, and cart, as well as various pages such as the home page, product description page, and checkout page. These pages and components are typically developed by different teams. With traditional frontend development approaches, coordinating these different teams can be a challenge, especially when multiple teams need to work on the same application concurrently.

Micro frontends offer a solution to this problem by applying the principles of microservices to the frontend. By breaking it down into small, independent parts, each team can develop and deploy their parts independently, without worrying about affecting other components.

There are several ways to build micro frontends, each one has its pros and cons. In this article, we will focus on a particular method for building a micro frontend app, which involves Module Federation and Next.js.

Module federation intro

Module Federation is a feature introduced in Webpack 5 that enables developers to dynamically share and consume JavaScript modules (also known as dependencies or components) across multiple applications at runtime. This approach to code sharing allows for better collaboration between development teams working on separate parts of an application.

Simply speaking a monolithic frontend application can be divided into smaller parts with separate repositories and independent build and deployment. As a result, there will be a Host application that consumes modules from smaller Remote applications.

Next.js intro

Next.js is a React framework that provides web applications with server-side rendering and static website generation. It has a file system-based routing system, where each file in the /pages directory corresponds to a specific route in the application. React components exported from these files are rendered when users navigate to those routes. And with /components folder typically containing reusable components that can be imported and used across different pages.

Next.js also provides a powerful feature called getServerSideProps. This function allows developers to fetch data from an external API or database during server-side rendering. The data can be fetched and processed on the server, resulting in faster initial load times and better SEO.

/pages/index.js - represents home page

While Next.js is powerful and offers a lot of features for building a modern frontend application, it can also be challenging to maintain as the codebase grows in size and complexity. This is because Next.js has a monolithic nature, where a single project serves as a client application for multiple services or features.

To overcome this issue, Module Federation can be used to divide the Next.js app into smaller, more independent parts. Thankfully, the Module Federation Team has announced that all their work regarding Next.js will be available in open source, enabling developers to leverage this approach in their projects.

Next.js with Module Federation

By using Module Federation with Next.js, the application can be divided into a Host app and one or more Remote apps. The Host app is responsible for rendering the application shell and can consume code from the Remote apps. These Remote apps are built and deployed separately, allowing for independent development and testing.

As an example, suppose we have a Home page in our Next.js application consisting of various components such as Hero, Promo, Search, and FeaturedProducts. Instead of importing these components locally, Module Federation enables us to import them from Remote applications.

Each remote component can have its own getServerSideProps, this means that remote components can be responsible for both data fetching and rendering their data. Implementation details are hidden which enables development teams to work autonomously on their respective micro frontends, leading to more efficient collaboration and faster development cycles.

Moreover, changes made to remote components can be automatically reflected in the Host app at runtime, without any additional deployment or reboot. This enables a highly agile development process, where developers can make changes to remote components and see the results immediately in the Host app.

In a typical micro frontend architecture utilizing Module Federation, each page may import multiple modules from remote applications. However, it is quite common for a page to import only one primary module that represents the complete page or a significant portion of it from a remote application.

This approach has some advantages:

  1. Simplified Integration: Importing a single module per page simplifies the integration process, as there are fewer dependencies to manage and coordinate.

  2. Clear Responsibility: Each remote application is responsible for a specific page or functionality, resulting in a clear separation of concerns and making it easier to understand and maintain the overall application.

  3. Faster Load Times: Loading a single module per page can potentially reduce the amount of data fetched and processed by the browser, leading to improved performance and faster load times.

  4. Enhanced Autonomy: Development teams can work independently on their respective pages or micro frontends without affecting other parts of the application, fostering better collaboration and efficient development processes.

  5. Easier Updates: With a single module per page, updates or changes can be made in isolation without impacting other pages, streamlining the deployment process.

Despite these benefits, it's important to consider the specific requirements and constraints of a project to determine the optimal approach. In some cases, importing multiple modules per page might be necessary to achieve the desired functionality or maintainability.

Setting up

It is highly recommended to reference this repository, featuring various Module Federation application examples: github.com/module-federation/module-federat..

Assuming there are two empty Next.js projects created by create-next-app with installed @module-federation/nextjs-mf, one is Host and another is Remote. Host will run on port 3000, Remote on port 3001.

There are only three steps to make Module Federation work with Next.js:

  1. On the Remote app add NextFederationPlugin to next.config.js and define modules ( components or pages) that need to be shared.

     // remote/next.config.js
     const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
    
     const nextConfig = {
       reactStrictMode: true,
       webpack(config, { isServer }) {
         config.plugins.push(
           new NextFederationPlugin({
             name: 'remote',
             filename: 'static/chunks/remoteEntry.js',
             exposes: {
               // specify exposed pages and components
               './SomePage': './pages/somePage.js',
               './SomeComponent': './components/someComponent.js'  
             },
             shared: {
                // specify shared dependencies 
                // read more in Shared Dependencies section
             },
           })
         );
    
         return config;
       },
     }
    
  2. On the Host app add NextFederationPlugin to next.config.js and specify remotes that should be consumed.

     // host/next.config.js
     const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
    
     const remotes = (isServer) => {
       const location = isServer ? 'ssr' : 'chunks';
       return {
         // specify remotes
         remote: `remote@http://localhost:3001/_next/static/${location}/remoteEntry.js`,
       };
     }
    
     const nextConfig = {
       reactStrictMode: true,
       webpack(config, { isServer }) {
         config.plugins.push(
           new NextFederationPlugin({
             name: 'host',
             filename: 'static/chunks/remoteEntry.js',
             remotes: remotes(isServer),
             exposes: {
               // Host app also can expose modules
             }
           })
         );
    
         return config;
       },
     }
    
  3. Now remote modules can be consumed by the Host app using next/dynamic

     // host/pages/someLocalPage.js
     import dynamic from 'next/dynamic';
    
     const RemotePage = dynamic(() => import('remote/SomePage'));
    
     export function LocalPage(props) {
       return <RemotePage {...props} />
     }
    
     export const getServerSideProps = async (ctx) => {
       const remotePage = await import('remote/SomePage');
    
       if (remotePage.getServerSideProps) {
         return remotePage.getServerSideProps(ctx)
       }
    
       return {
         props: {},
       }
     }
    
     export default LocalPage;
    

Shared dependencies

In the process of developing web applications, often needed to use third-party dependencies. This is true for both traditional apps and micro frontend apps, where Host and Remote apps might share dependencies like CSS-in-JS, state management, or data-fetching libraries.

To avoid duplicating resources and keep things running smoothly, the Module Federation Plugin configuration has a shared option. This lets developers mark certain dependencies as shared resources across different apps or components in a micro frontend setup.

Through the shared option — remotes will depend on host dependencies, if the host does not have a dependency, the remote will download its own. No code duplication, but built-in redundancy.

Here is an example with a shared Chakra UI library.

const { NextFederationPlugin } = require('@module-federation/nextjs-mf');

const nextConfig = {
  webpack(config, { isServer }) {
    config.plugins.push(
      new NextFederationPlugin({
        name: 'shop',
        filename: 'static/chunks/remoteEntry.js',
        remotes: {},
        exposes: {
          './Featured': './pages/featured.js',
          './Search': './pages/search.js',
        },
        shared: {
          '@emotion/': {
            eager: true,
            requiredVersion: false,
            singleton: true,
          },
          '@chakra-ui/': {
            eager: true,
            requiredVersion: false,
            singleton: true,
          },
        },
      })
    );

    return config;
  },
}

module.exports = nextConfig

The singleton option is used to ensure that only a single instance of a shared module is used across all federated applications. When you set the singleton property to true, you're telling Webpack that there must be only one instance of the shared module loaded and shared between all applications.

Hot reload

To enable hot reloading of the node server (not client) in production, you need add revalidate utility into _document.js. This is recommended, without it - servers will not be able to pull remote updates without a full restart.

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

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    if(process.env.NODE_ENV === "development" && !ctx.req.url.includes("_next")) {
      await revalidate().then((shouldReload) =>{
        if (shouldReload) {
          ctx.res.writeHead(302, { Location: ctx.req.url });
          ctx.res.end();
        }
      });
    } else {
      ctx?.res?.on("finish", () => {
        revalidate()
      });
    }

    const chunks = await flushChunks()

    const initialProps = await Document.getInitialProps(ctx);
    return {
      ...initialProps,
      chunks
    };
  }

  render() {
    return (
      <Html>
        <Head>
          <FlushedChunks chunks={this.props.chunks} />
        </Head>
        <body>
        <Main />
        <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

Demo project

Here you can find a demo project for Module Federation with Next.js and Chakra UI.

More info