iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

Trying Out Module Federation with Next.js

に公開
4

Introduction

Instead of developing an application as one large project, microservices—where each domain is separated and operates independently—are being introduced in various places.
In my subjective view, it feels like microservices are often implemented by splitting the backend for each domain.
However, it's not just the backend that we might want to split by domain.
There are cases where we want to split the frontend into domain units and develop them independently as well.
In such cases, the concept of Micro Frontends comes in handy.
So, this time, I'm going to use Module Federation, a technique for Micro Frontends, to get a small taste of what it's like.
Note that this article will not cover the general details of microservices or micro frontends. It will focus primarily on Module Federation, which is one of the Micro Frontend techniques.
Let's get started.

What is Module Federation?

Module Federation involves providing JavaScript code split into small units (≈ chunks) and loading them asynchronously or synchronously.
1677586047168.png
Quoted from Building MicroFrontends with webpack Module Federation
Since it works by loading remote code rather than installing it into the project, you can construct screens in the way you need, when you need them.
And because it's read remotely, there isn't much difference between local and online behavior.
Furthermore, Module Federation allows you to specify library versions between shared projects.
This allows specific versions to be loaded for the project using the library.
I haven't verified this personally, so I can't say for sure, but I believe that by fixing library versions in the shared section of Module Federation, it might be possible to operate even if the versions of the same library used across projects differ significantly.
While it offers great flexibility by distributing static files in chunks and absorbing version differences, this can also be a disadvantage.
Due to the high degree of freedom, the flow of sharing between projects can become bidirectional.
If that happens, you may fall into a "distributed monolith" where each project depends on other projects.
Also, due to the constraint of distributing static files remotely, projects that strictly require authentication will need to take measures, such as opening up endpoints for Module Federation.
As described above, while there are many convenient aspects, there is a concern that it could turn into a massive distributed monolith if not properly managed.
It's necessary to use it with these points in mind, but some things are hard to grasp without actually running it. Next, let's try out Module Federation with Next.js.

Implementing Module Federation with Next.js

Module Provider

Environment Setup

Since we'll be using webpack for Module Federation this time, create a project using the npx create-next-app@latest command.
Most options can be selected as you prefer, but you must choose Pages Router instead of App Router.
Once the project is created, install @module-federation/nextjs-mf.
Now, the basic preparation is complete.

Exporting the module

Next, we'll create the component we want to export.
Note that while we are exporting a component this time, you can also export page files.
This flexibility is a strength of Module Federation, so feel free to try it out if you're interested.
Create a src/components/Test.tsx file and add the following code:

import { CSSProperties, ReactNode } from "react";
export interface TestProps extends CSSProperties {
  label: string;
  children: ReactNode;
}
function Test({ children, label }: TestProps) {
  return (
    <div>
      <p>{label}</p>
      {children}
    </div>
  );
}
export default Test;

The key point is that the component is exported using default.
Since it makes importing easier on the consumer side, it's generally better to use default unless there's a special reason not to.
Now, the provider side is ready for Module Federation. Let's actually export the module.

Configuring Module Federation in next.config.js

Update the next.config.js file at the root of the project as follows:

/** @type {import('next').NextConfig} */
const { NextFederationPlugin } = require("@module-federation/nextjs-mf");
const moduleConfig = {
  name: "next2",
  filename: "static/chunks/remoteEntry.js",
  exposes: {
    "./test": "./src/components/Test.tsx",
  },
};
module.exports = {
  webpack(config, options) {
    config.plugins.push(new NextFederationPlugin(moduleConfig));
    return config;
  },
};

The key point is the moduleConfig variable.
Here, we set the following properties:

  1. A name representing the entire module: name
  2. The name of the file containing the logic for the exported modules: filename
  3. The names and file paths of the modules to be exported: exposes

Finally, pass these options to the NextFederationPlugin from the @module-federation/nextjs-mf package we installed earlier.
By building the project with this configuration, you can provide a file that bundles the modules.
Let's try it out.
Modify the dev script in the scripts section of package.json as follows and run it:

"dev": "PORT=3001 NEXT_PRIVATE_LOCAL_WEBPACK=true next dev

PORT is changed just to account for the consumer side later and is not a required setting.
However, the NEXT_PRIVATE_LOCAL_WEBPACK=true part is essential for the build to complete, so be sure to include it.
Once it's running, access http://localhost:3001/_next/static/chunks/remoteEntry.js.
You should see a file like the following:

!function(){var __webpack_modules__={6478:function(e,t,n){"use strict";n.r(t);var r=n(4870),o=n.n(r),i=n(4347),a=n.federation;for(var s in n.federation={},o())n.federation[s]=o()[s];for(var s in a)n.federation[s]=a[s];if(!n.federation.instance){let e=[!!i.A&&(0,i.A)()].filter(Boolean);n.federation.initOptions.plugins=n.federation.initOptions.plugins?n.federation.initOptions.plugins.concat(e):e,n.federation.instance=n.federation.runtime.init(n.federation.initOptions),n.federation.attachShareScopeMap&&n.federation.attachShareScopeMap(n),n.federation.installInitialConsumes&&n.federation.installInitialConsumes()}},2013:function(e,t,n){"use strict";function r(){return"next2:0.1.0"}function o(){return!1}function i(){return"undefined"!=typeof window}n.r(t),n.d(t,{FederationHost:function(){return tI},getRemoteEntry:function(){return e7},getRemoteInfo:function(){return e5},init:function(){return tP},loadRemote:function(){return tN},loadScript:function(){return eZ.k0},loadScriptNode:function(){return eZ.oe},loadShare:function(){return tA},loadShareSync:function(){return tk},preloadRemote:function(){return tx},registerGlobalPlugins:function(){return L},registerPlugins:function(){return tD},registerRemotes:function(){return tj}});let a="[ Federation Runtime ]";function s(e,t){e||l(t)}function l(e){if(e instanceof Error)throw e.message=`${a}: ${e.message}`,e;throw Error(`${a}: ${e}`)}function u(e){e instanceof Error?(e.message=`${a}: ${e.message}`,console.warn(e)):console.warn(`${a}: ${e}`)}function c(e,t){return -1===e.findIndex(e=>e===t)&&e.push(t),e}function f(e){return"version"in e&&e.version?`${e.name}:${e.version}`:"entry"in e&&e.entry?`${e.name}:${e.entry}`:`${e.name}`}function h(e){return void 0!==e.entry}function d(e)
/** Omitted */

This completes the setup for the provider side.
Now, let's use this module.

Module Consumption

Environment Setup

Just like the provider side, create a Next.js project and install @module-federation/nextjs-mf.

Importing in next.config.js

Add the following configuration to next.config.js:

/** @type {import('next').NextConfig} */
// next.config.js
const { NextFederationPlugin } = require("@module-federation/nextjs-mf");
module.exports = {
  webpack(config, options) {
    const moduleConfig = {
      name: "next1",
      remotes: {
        next2: `next2@http://localhost:3001/_next/static/chunks/remoteEntry.js`,
      },
      filename: "static/chunks/remoteEntry.js",
    };
    config.plugins.push(new NextFederationPlugin(moduleConfig));
    return config;
  },
};

It is basically the same as the provider side, but while the provider side used the exposes property, the consumer side sets the remotes property.
In remotes, you connect the name defined on the provider side and the URL to access remoteEntry.js using an @ symbol.
Now, the setup for importing is complete.

Using the Component

Let's use it right away. Add the following to any file:

import dynamic from "next/dynamic";
const Test = dynamic(() => import("next2/test"), { ssr: false });
export default function Home() {
  return (
    <Test label="eeee">
      <p>This is importing a module uuuuuuuuuu</p>
    </Test>
  );
}

Since this is a static component, using dynamic isn't strictly necessary.
However, if the provided component changes dynamically due to external requests, a Hydration Error will occur unless you use lazy loading.
Therefore, it's generally safer to load it lazily.
Note that while the documentation recommends using React.lazy, I'm using dynamic here as part of the experiment since it didn't cause any Hydration Errors.

Add the "dev": "PORT=3001 NEXT_PRIVATE_LOCAL_WEBPACK=true next dev” script to package.json. After running it and accessing http://localhost:3000, the following screen will be displayed:
2024-07-23_23h21_13.png
Wow! The component implemented on the provider side is being displayed.
Moreover, it seems that children and the label props are also working.
As demonstrated, you can use the implementation from another project without having to provide or import it as a library.
I mentioned that flexibility is the strength of Module Federation, and even this simple example makes you feel its potential.
It opens up possibilities, such as being able to use old and new logic together by providing a breaking change as "version 2" in a separate directory.
It's very straightforward for simple implementations, so please give it a try.

Adding Types to the Module

In the previous section, I showed a simple example of Module Federation and confirmed that the application works.
Of course, that works fine, but a problem remains.
The issue is that there is no auto-completion at all.
For example, if you implement the previous example in VSCode, you'll see an error like this:
2024-07-23_23h29_28.png
When the application runs, it fetches the target component from the provider URL, so it works even with this implementation.
However, VSCode has no way of knowing that, and since the target module isn't in node_modules, it triggers an error.
Furthermore, for the Test component, VSCode doesn't even recognize the import, so prop completion doesn't work.
While you can manually type them in and the values will be reflected, it's quite inconvenient.
Therefore, in this section, we will set things up so that type completion works even when using Module Federation.

Preparation

First, install @module-federation/typescript in each project.
After that, update each next.config.js as follows:

/** Provider side */
const { NextFederationPlugin } = require("@module-federation/nextjs-mf");
const { FederatedTypesPlugin } = require("@module-federation/typescript");
const moduleConfig = {
  name: "next2",
  filename: "static/chunks/remoteEntry.js",
  exposes: {
    "./test": "./src/components/Test.tsx",
  },
};
module.exports = {
  webpack(config, options) {
    config.plugins.push(new NextFederationPlugin(moduleConfig));
    config.plugins.push(
      new FederatedTypesPlugin({
        federationConfig: moduleConfig,
      })
    );
    return config;
  },
};
/** Consumer side */
const { NextFederationPlugin } = require("@module-federation/nextjs-mf");
const { FederatedTypesPlugin } = require("@module-federation/typescript");
module.exports = {
  webpack(config, options) {
    const moduleConfig = {
      name: "next1",
      remotes: {
        next2: `next2@http://localhost:3001/_next/static/chunks/remoteEntry.js`,
      },
      filename: "static/chunks/remoteEntry.js",
    };
    config.plugins.push(new NextFederationPlugin(moduleConfig));
    config.plugins.push(
      new FederatedTypesPlugin({
        federationConfig: moduleConfig,
      })
    );
    return config;
  },
};

That's all it takes.
After restarting both with the dev script, an @mf-types directory will be created under the project consuming the module, and type definition files will be generated.
Now, completion is available.
If you import it like this on the consumer side:

const Test = dynamic(() => import("../../@mf-types/next2/test"), {
  ssr: false,
});

Auto-completion will work in VSCode.
2024-07-24_00h27_57.png
However, it won't work as-is.
The @mf-types directory only contains type definition files, so importing from that path won't find the actual implementation, resulting in an error.
To run the app, you need to import from next2/test.
To resolve this, add the following settings to tsconfig.json:

{
  "compilerOptions": {
    /** Omitted */
    "baseUrl": ".",
    "paths": {
      "*": ["./@mf-types/*"]
    }
  }
  /** Omitted */
}

By configuring it this way, you can refer to the type definition files via next2/test in VSCode, while the running application refers to the actual next2/test entity.
It's very convenient.
However, note that this type generation setup might not work perfectly in all cases.
I don't fully understand the root cause yet, but type definition files were not generated successfully in the following scenarios:

  • A wrapper component using Next.js dynamic at the top level.
  • A component where the urql Provider is at the top level.

While it is useful, there seem to be some limitations.

Challenges when using Module Federation with Next.js

Up until now, we have been implementing Module Federation.
Since there are convenient and novel aspects, it opens up many possibilities, but of course, there are also challenges.
In this section, I will discuss those challenges.
Note that most of the points are related to the library we used this time rather than Next.js itself, but I will describe the challenges I encountered while implementing Module Federation.

Build Tool Limitations

The build tools that officially support Module Federation, as far as I have confirmed, are Webpack and Nx.
However, it has been reported that new development for Webpack is set to stop, as mentioned in this article.
Also, in recent years, the momentum of Vite has been tremendous, as shown below, and there is a high possibility that building with Vite will become the norm in a few years.
Quoted from State of Javascript 2023
Quoted from State of Javascript 2023
While Webpack still remains number one, considering the possibility that it will fall out of use, the cost of future replacement needs to be considered.
I have never used Nx and don't know much about it, so I won't touch upon it here.
Note that although Vite doesn't officially support Module Federation, a plugin is being developed.
Therefore, concerns about build tools might disappear in the future, but since the future is uncertain, the limitations of build tools will likely continue to be a challenge for Module Federation.

Cannot be used with App Router

Recently, Next.js has introduced the App Router and recommends its use.
Therefore, when starting new projects with Next.js, App Router is likely to be the primary choice.
However, the library used this time does not support App Router.
Issues have been raised inquiring about App Router support, but the responses consistently state that it is not supported or that App Router compatibility is currently not possible.
https://github.com/module-federation/core/issues/1943
https://github.com/module-federation/core/issues/1946
https://github.com/module-federation/core/pull/2002
At the end of the pull request, a member of the module provided the following comment:

There will never be module federation with app router, not my ecosystem - not for next.js

Vercel will make proprietary system for app router most likely.

"Module Federation with App Router will never happen! Vercel will likely build their own proprietary system"—you can really feel the respondent's frustration with App Router here.
It is important to keep in mind that it cannot be used with App Router, which is currently the recommended approach for Next.js.

CORS Considerations

With Module Federation, you can use components without having to install them as dependencies.
Moreover, because the components are being pulled from a running project, they also execute external requests.
However, you must be careful with the URLs of those external requests.
While the components are indeed the same as those running on the provider side, when they are actually executed, the reference URL becomes that of the consumer side.
Let's look at a specific example.
Suppose you have the following component:

import { ReactNode, useEffect, useState } from "react";
export interface TestProps {
  children: ReactNode;
}
export function Test({ children }: TestProps) {
  const [user, setUser] = useState("");
  useEffect(() => {
    const fetchUsers = async () => {
      const response = await fetch("/api/user"); // Fetches from a relative path
      const data = await response.json();
      setUser(data.name);
    };
    fetchUsers();
  }, []);
  return (
    <div>
      <p>{user}</p>
      {children}
    </div>
  );
}
export default Test;

Upon mounting, the component executes a request to /api/user.
The project implementing this component has an /api/user API endpoint and the project URL is http://localhost:3001.
Next, suppose you fetch this component using Module Federation and call it from a consumer project instead of the provider project.
Assume the consumer project's URL is http://localhost:3000.
In this case, when the component mounts, it will request http://localhost:3000/api/user instead of http://localhost:3001.
Since the consumer project does not have the /api/user endpoint, this results in a 404 error.
To avoid this, the fetch function inside the component must specify the provider project's absolute URL rather than a relative path.
By specifying the URL, you can direct the request to the intended endpoint.
However, since this URL differs from the host environment's origin, it will trigger a CORS error by default.
Therefore, it is important to note that adjustments, such as relaxing CORS policies, will be necessary.

Layout breakdown due to style overlap

When using other components via Module Federation, you fetch not only the JavaScript functionality but also the CSS and HTML-related configurations.
Therefore, it is necessary to add things like prefixes in each project to avoid layout conflicts.

Hydration requires attention even with Module Federation

To be honest, this isn't strictly about Module Federation, but Hydration Errors still occur even when using Module Federation.
Therefore, it is necessary to handle this using lazy loading with functions like dynamic or lazy.

Conclusion

In this article, we tried out Module Federation with Next.js.
Since it is quite simple and allows for flexible exporting and importing, it opened up a lot of possibilities, such as developing each page independently and then embedding them to form a single application.
However, Module Federation is not a silver bullet, and even in my short time using it, I've felt some constraints here and there.
Therefore, I want to continue deepening my understanding with a stance of not being obsessed with Module Federation, while still exploring its potential.
Thank you for reading this far.

Discussion

umekawahibikiumekawahibiki

早速ご対応ありがとうございます!
こちらの記事大変参考になりました!!