Mastering Module Federation: A Beginner's Guide to Scalable Micro-Frontends (2025 Edition)

Mastering Module Federation: A Beginner’s Guide to Scalable Micro-Frontends (2025 Edition)

1. Introduction to Module Federation

Welcome to the exciting world of Module Federation! This guide is designed for absolute beginners who want to understand and implement this powerful technology to build scalable and maintainable web applications.

What is Module Federation?

Module Federation, introduced in Webpack 5, is a groundbreaking feature that allows multiple, independently built and deployed JavaScript applications to share code and assets at runtime. Instead of bundling all dependencies into a single, monolithic application, Module Federation enables dynamic loading of modules from other applications (known as “remotes”) into a “host” application.

Imagine you have several distinct parts of a large web application—like a product catalog, a shopping cart, and a user profile—each developed by different teams and potentially using different frameworks. Before Module Federation, sharing components or logic between these parts was often a complex dance of publishing libraries to NPM and dealing with version conflicts. Module Federation simplifies this by allowing these independent applications to expose and consume modules directly at runtime, fostering a true micro-frontend architecture.

Why Learn Module Federation? (Benefits, Use Cases, Industry Relevance)

Learning Module Federation is becoming increasingly crucial in modern web development, especially for large-scale projects. Here’s why:

  • Enables Micro-Frontend Architecture: This is the primary driver. Module Federation provides a robust solution for breaking down large, monolithic frontends into smaller, manageable, and independently deployable units. This allows for greater team autonomy, faster development cycles, and easier scaling of development efforts.
  • Independent Development & Deployment: Teams can develop, build, and deploy their micro-frontends without affecting other parts of the application. This reduces coordination overhead and allows for continuous delivery of features.
  • Code Sharing & Reduced Duplication: Instead of duplicating common libraries (like React, Angular, or Vue) across multiple applications, Module Federation allows you to share them. This significantly reduces bundle sizes and improves performance by ensuring a single instance of a shared library is loaded.
  • Framework Agnosticism (with caveats): While often used within a single framework ecosystem (e.g., all React apps), Module Federation can facilitate cross-framework integration, allowing you to combine micro-frontends built with different technologies into a unified experience.
  • Improved Performance: By dynamically loading only the necessary modules on demand (lazy loading), Module Federation can reduce the initial load time of your application.
  • Enhanced Maintainability: Smaller, self-contained micro-frontends are easier to understand, debug, and maintain compared to large, complex monoliths.
  • Industry Relevance: Major companies like Spotify and Salesforce are leveraging micro-frontend architectures, often powered by Module Federation, to manage their complex platforms. As web applications grow, the demand for developers proficient in this pattern will only increase.

A Brief History

Before Module Federation, various techniques were used to achieve modularity in frontends, such as iframes, build-time integration (NPM packages), and Web Components. While these offered some benefits, they often came with limitations like poor user experience, centralized release cycles, or complex state management.

Webpack 5 introduced Module Federation in 2020 as a direct answer to these challenges, providing a more robust and native solution for runtime module sharing. It quickly gained traction, and with the release of Module Federation 2.0 in 2024, the ecosystem has matured further, offering enhanced features like:

  • Decoupled Runtime: The runtime capabilities are now extracted to a standalone SDK, allowing for greater flexibility and standardization across different build tools (Webpack, Rspack).
  • Type Safety: Automatic generation and loading of types for TypeScript projects.
  • Debugging Tools: Enhanced debugging with a new Chrome DevTools.
  • Deployment Platforms Integration: Simplifies integration with deployment platforms via the mf-manifest.json file protocol.

Setting Up Your Development Environment

To follow along with this guide, you’ll need a basic development environment. We’ll primarily focus on a JavaScript/React setup using Node.js and a package manager.

Prerequisites:

  1. Node.js: Module Federation relies on Node.js for running build tools and development servers.

    • Installation: Download and install the latest LTS version from nodejs.org.
    • Verification: Open your terminal or command prompt and run:
      node -v
      npm -v
      
      You should see the installed versions.
  2. Code Editor: A good code editor will significantly improve your development experience. Visual Studio Code (VS Code) is highly recommended.

    • Installation: Download and install from code.visualstudio.com.
    • Recommended Extensions: For JavaScript/React development, consider extensions like ESLint, Prettier, and various React snippets.
  3. Terminal/Command Prompt: You’ll use your terminal to run commands, create projects, and start development servers.

Initial Project Setup (Monorepo Approach with npm workspaces):

For learning Module Federation, it’s often easiest to start with a monorepo structure. This allows you to manage multiple interdependent applications within a single repository.

Let’s create a basic monorepo using npm workspaces.

  1. Create a Root Directory:

    mkdir module-federation-tutorial
    cd module-federation-tutorial
    
  2. Initialize npm Monorepo: Create a package.json file at the root. You can do this manually or by running npm init and answering the prompts. The most important part is adding the "workspaces" field.

    // package.json at the root
    {
      "name": "module-federation-monorepo",
      "version": "1.0.0",
      "private": true,
      "workspaces": [
        "packages/*"
      ],
      "scripts": {
        "start:all": "npm run --workspaces start"
      }
    }
    
    • "private": true: Prevents the root package from being published to an npm registry.
    • "workspaces": ["packages/*"]: Tells npm to look for packages (your micro-frontends) inside the packages directory.
    • "start:all": A convenient script to start all applications in your workspaces (we’ll define start scripts in individual app package.json files later).
  3. Create the packages Directory:

    mkdir packages
    

Now your basic monorepo is set up! We’ll create our host and remote applications inside the packages directory.

2. Core Concepts and Fundamentals

In this section, we’ll dive into the fundamental building blocks of Module Federation. We’ll start with the two main roles an application can play: Host and Remote, and then explore how they interact through exposed and shared modules.

2.1 Host and Remote Applications

At its heart, Module Federation involves at least two types of applications:

  • Host Application (Container/Shell): This is the application that loads and orchestrates modules from other applications. It acts as the “shell” or “container” for your micro-frontends. It defines which remote applications it wants to consume.
  • Remote Application (Micro-Frontend): This is an independent application that “exposes” some of its code (components, functions, etc.) to be consumed by other applications. It can also act as a host itself, consuming modules from other remotes (bidirectional hosting).

Analogy: Think of a magazine. The Host Application is the magazine itself, providing the main structure, layout, and overall theme. The Remote Applications are individual articles or sections written by different authors. The magazine (host) “loads” these articles (remotes) into its pages, and the articles (remotes) can also include snippets or references from other articles.

Setting up our first Host and Remote (using React and Webpack)

Let’s create two simple React applications: a host-app and a remote-app-1.

1. Create host-app:

Navigate to the packages directory and create a new React app. We’ll use create-react-app for simplicity, but you could also set up a React app manually.

cd packages
npx create-react-app host-app --template typescript
cd host-app

Now, we need to modify the webpack.config.js for Module Federation. create-react-app hides its Webpack configuration, so we’ll “eject” it or use a library that allows custom Webpack configuration without ejecting. For a beginner-friendly approach, let’s keep it simple and directly configure Webpack without ejecting, which means we’ll install Webpack and its plugins manually for this example.

Important Note for create-react-app: Ejecting create-react-app (npm run eject) reveals the underlying Webpack config, but it’s a one-way operation. For our tutorial, we’ll simulate a custom Webpack setup from scratch in our packages to make the configuration explicit, as create-react-app defaults do not expose Module Federation.

Let’s start over with a minimal Webpack setup for both host and remote.

Revisiting Setup: Minimal Webpack Project

Let’s go back to our module-federation-tutorial/packages directory. We’ll create two plain React projects without create-react-app to have full control over Webpack.

1. Initialize host-app:

cd module-federation-tutorial/packages
mkdir host-app
cd host-app
npm init -y
npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server

Create an src directory and an index.tsx file inside host-app/src:

// host-app/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';

const App = () => {
  return (
    <div>
      <h1>Host Application</h1>
      {/* Remote component will be loaded here */}
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

Create an index.html file inside host-app/public:

<!-- host-app/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Host App</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

Create host-app/webpack.config.js:

// host-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index.tsx',
  mode: 'development',
  devServer: {
    port: 3000,
    historyApiFallback: true, // For React Router
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react', '@babel/preset-typescript'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'hostApp',
      remotes: {
        // We'll define remotes here later
        // remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  output: {
    publicPath: 'http://localhost:3000/', // Important for Module Federation
  },
};

Add a start script to host-app/package.json:

// host-app/package.json
{
  "name": "host-app",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --open"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@babel/preset-react": "^7.20.0",
    "@babel/preset-typescript": "^7.20.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@types/webpack": "^5.28.0",
    "@types/webpack-dev-server": "^4.7.0",
    "babel-loader": "^9.1.0",
    "html-webpack-plugin": "^5.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "typescript": "^5.0.0",
    "webpack": "^5.75.0",
    "webpack-cli": "^5.0.0",
    "webpack-dev-server": "^4.11.0"
  }
}

2. Initialize remote-app-1:

Go back to the packages directory and create remote-app-1.

cd ../
mkdir remote-app-1
cd remote-app-1
npm init -y
npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server

Create an src directory and an index.tsx file inside remote-app-1/src:

// remote-app-1/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyButton from './MyButton';

const App = () => {
  return (
    <div>
      <h1>Remote App 1</h1>
      <MyButton />
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

Create remote-app-1/src/MyButton.tsx (this will be our exposed component):

// remote-app-1/src/MyButton.tsx
import React from 'react';

const MyButton: React.FC = () => {
  const handleClick = () => {
    alert('Hello from Remote App 1!');
  };

  return (
    <button onClick={handleClick} style={{ padding: '10px', backgroundColor: 'lightblue', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
      Click Me (Remote 1 Button)
    </button>
  );
};

export default MyButton;

Create an index.html file inside remote-app-1/public:

<!-- remote-app-1/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Remote App 1</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

Create remote-app-1/webpack.config.js:

// remote-app-1/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index.tsx',
  mode: 'development',
  devServer: {
    port: 3001, // Different port for remote app
    historyApiFallback: true,
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react', '@babel/preset-typescript'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp1',
      filename: 'remoteEntry.js', // The file that describes the exposed modules
      exposes: {
        './MyButton': './src/MyButton', // Expose MyButton component
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  output: {
    publicPath: 'http://localhost:3001/', // Important for Module Federation
  },
};

Add a start script to remote-app-1/package.json:

// remote-app-1/package.json
{
  "name": "remote-app-1",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --open"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@babel/preset-react": "^7.20.0",
    "@babel/preset-typescript": "^7.20.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@types/webpack": "^5.28.0",
    "@types/webpack-dev-server": "^4.7.0",
    "babel-loader": "^9.1.0",
    "html-webpack-plugin": "^5.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "typescript": "^5.0.0",
    "webpack": "^5.75.0",
    "webpack-cli": "^5.0.0",
    "webpack-dev-server": "^4.11.0"
  }
}

Now, we need to link remote-app-1 to host-app.

Update host-app/webpack.config.js to consume remote-app-1:

Modify the remotes section:

// host-app/webpack.config.js
// ... (rest of the file)
plugins: [
    new ModuleFederationPlugin({
      name: 'hostApp',
      remotes: {
        remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js', // Define remote
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
// ... (rest of the file)

Update host-app/src/index.tsx to use the remote component:

// host-app/src/index.tsx
import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';

// Dynamically import the remote component
const RemoteButton = lazy(() => import('remoteApp1/MyButton'));

const App = () => {
  return (
    <div>
      <h1>Host Application</h1>
      <h2>Loading Remote Component:</h2>
      <Suspense fallback={<div>Loading Remote Button...</div>}>
        <RemoteButton />
      </Suspense>
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

Running the Applications:

Open two separate terminal windows.

Terminal 1 (for remote-app-1):

cd module-federation-tutorial/packages/remote-app-1
npm start

This will start remote-app-1 on http://localhost:3001. You should see “Remote App 1” and the “Click Me (Remote 1 Button)” button.

Terminal 2 (for host-app):

cd module-federation-tutorial/packages/host-app
npm start

This will start host-app on http://localhost:3000. You should see “Host Application”, “Loading Remote Component:”, and after a brief moment, the “Click Me (Remote 1 Button)” button will appear, indicating that the component was successfully loaded from remote-app-1.

Explanation:

  • ModuleFederationPlugin: This is the core Webpack plugin that enables Module Federation.
  • name: A unique identifier for the current application (e.g., hostApp, remoteApp1).
  • filename (for Remotes): Specifies the name of the bundle file that will expose modules (remoteEntry.js). This file acts as an entry point for other applications to discover and load modules.
  • exposes (for Remotes): An object where keys are the public names of the modules you want to expose, and values are the paths to those modules within the remote application. For example, './MyButton': './src/MyButton' means MyButton from src/MyButton.tsx can be imported as remoteApp1/MyButton.
  • remotes (for Hosts): An object where keys are the local names you’ll use to import remote modules, and values are the name of the remote application followed by its publicPath and filename. For example, remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js' means the hostApp can now import modules from remoteApp1 using remoteApp1/....
  • shared: This is crucial for performance and preventing version conflicts. It specifies libraries that should be shared between federated applications.
    • singleton: true: Ensures that only one instance of the shared module is loaded, even if multiple remotes (or the host) require it. This is essential for libraries like React to avoid issues with contexts, hooks, and state.
    • requiredVersion: '^18.2.0': Specifies the acceptable version range for the shared dependency. If a remote requires a different version, Module Federation attempts to reconcile them, prioritizing the host’s version or a compatible one.
  • publicPath in output: This is critical for Module Federation. It tells Webpack where to find the bundled assets for the application, especially remoteEntry.js. It must be an absolute URL for cross-application loading.
  • lazy and Suspense: When consuming remote components, it’s best practice to use React.lazy for code-splitting and React.Suspense to provide a fallback (like a loading indicator) while the remote module is being fetched.

Exercise 2.1: Create another Remote Application

  1. Create a new application named remote-app-2 inside the packages directory.
  2. Install all necessary dependencies, similar to remote-app-1.
  3. Configure its webpack.config.js to run on port 3002.
  4. Create a component named MyHeader in remote-app-2/src/MyHeader.tsx that displays a simple header (e.g., “Hello from Remote App 2 Header!”).
  5. Expose MyHeader in remote-app-2’s Module Federation configuration.
  6. Modify the host-app/webpack.config.js to include remote-app-2 in its remotes configuration.
  7. Modify host-app/src/index.tsx to dynamically load and display MyHeader from remote-app-2 below the RemoteButton.
  8. Run all three applications and verify that both remote components are displayed in the host application.

2.2 Exposing and Consuming Modules

As seen in the previous section, the core mechanism of Module Federation revolves around:

  • Exposing Modules (from Remote): A remote application explicitly defines which of its internal modules (components, utilities, data, etc.) it wants to make available to other applications. This is done via the exposes property in the ModuleFederationPlugin.
  • Consuming Modules (in Host): A host application declares which remote applications it intends to use and how to locate their remoteEntry.js file. This is done via the remotes property in the ModuleFederationPlugin. Once configured, the host can import these remote modules as if they were local.

Detailed Explanation:

Exposing: When you expose a module, you give it an “internal” name within the federated system that other applications will use.

// remote-app/webpack.config.js
// ...
plugins: [
  new ModuleFederationPlugin({
    name: 'remoteApp',
    filename: 'remoteEntry.js',
    exposes: {
      './MyComponent': './src/components/MyComponent', // Exposing a React component
      './utils': './src/utils/math',                   // Exposing a utility file
      './store': './src/store/index.ts',               // Exposing a state store
    },
    // ...
  }),
],
// ...

Here, remoteApp/MyComponent, remoteApp/utils, and remoteApp/store become public interfaces for other applications to use.

Consuming: A host application references the exposed modules by prefixing the remote application’s declared name (from its remotes config) with the exposed module’s alias.

// host-app/src/App.tsx
import React, { lazy, Suspense } from 'react';

const RemoteMyComponent = lazy(() => import('remoteApp/MyComponent'));
const { add } = await import('remoteApp/utils'); // For non-React modules, dynamic import can be direct

const App = () => {
  const sum = add(5, 3);
  return (
    <div>
      <h1>Host App</h1>
      <Suspense fallback={<div>Loading Remote Component...</div>}>
        <RemoteMyComponent />
      </Suspense>
      <p>Result from remote util: {sum}</p>
    </div>
  );
};

Notice how import('remoteApp/MyComponent') and import('remoteApp/utils') work. The remoteApp part comes from the key defined in the host’s remotes configuration, and MyComponent or utils comes from the exposes configuration of the remoteApp.

2.3 Shared Modules and Dependency Management

One of the most significant advantages of Module Federation is its intelligent dependency sharing mechanism. In a micro-frontend architecture, it’s very common for multiple applications to depend on the same libraries (e.g., React, Redux, Material-UI). Without proper sharing, each application would bundle its own copy of these libraries, leading to:

  • Increased Bundle Sizes: Larger downloads for the user.
  • Performance Issues: Redundant loading and execution of the same code.
  • Runtime Errors: Especially with libraries like React, having multiple instances on the page can lead to context breaks, hook issues, and unexpected behavior.

The shared property in the ModuleFederationPlugin addresses these issues.

Detailed Explanation of shared configuration:

// Example shared configuration
shared: {
  react: {
    singleton: true,
    requiredVersion: '^18.2.0', // Host will try to use its own version within this range
    eager: false,               // Load lazily by default
  },
  'react-dom': {
    singleton: true,
    requiredVersion: '^18.2.0',
    eager: false,
  },
  lodash: {
    singleton: true,
    requiredVersion: '^4.17.21',
    // No explicit eager, defaults to lazy
  },
  '@my-org/design-system': { // Sharing a custom design system library
    singleton: true,
    requiredVersion: '^1.0.0',
  }
}

Key properties within shared:

  • singleton: true: This is paramount for libraries that should only have one instance across all federated applications loaded on the page. React is the prime example. If set to true, Module Federation will ensure that if the host and a remote both depend on React, only one copy (usually the host’s version, if compatible) is loaded and used by both.
  • requiredVersion: Specifies a version range (using semver) that the application is compatible with. Module Federation will attempt to use a shared module if its version falls within this range. If a conflicting version is found, the system tries to find the highest compatible version.
    • strictVersion: true (optional): If set to true with requiredVersion, Module Federation will throw an error if the host and remote cannot agree on a compatible version. This ensures strict version control but can be less forgiving.
  • eager: true (optional): By default, shared modules are loaded lazily (only when first requested). Setting eager: true means the shared module will be loaded immediately when the entry point of the application loads. Use with caution, as it can increase initial load times, but might be necessary for some core dependencies.
  • import (optional): Allows you to specify the actual module to be loaded. Useful if the package.json name differs from the internal import path.
  • shareKey (optional): Allows a different key to be used for sharing than the module name itself.

How Module Federation Resolves Shared Dependencies:

  1. When an application starts, it builds a graph of its own dependencies and the shared dependencies it needs.
  2. When a remote module is loaded, Module Federation compares its shared dependencies with those already loaded by the host.
  3. If a compatible version of a shared module is already loaded (and singleton: true is set), the remote will reuse it.
  4. If a compatible version is not available, or if the singleton rule isn’t met (e.g., singleton: false), the remote might load its own copy or attempt to resolve a new shared version based on requiredVersion and strictVersion rules.

Exercise 2.2: Verify Shared Dependencies

  1. Ensure your host-app and remote-app-1 webpack.config.js files have the shared configuration for react and react-dom with singleton: true and requiredVersion: '^18.2.0'.
  2. Start both applications (remote-app-1 first, then host-app).
  3. Open your browser’s developer tools (usually F12).
  4. Go to the “Network” tab.
  5. Filter by “JS” or “JavaScript”.
  6. You should observe that react.js and react-dom.js (or their bundled equivalents) are loaded only once by either the host or the first remote that needs them, not by both independently. If you see two separate downloads for React, check your singleton and requiredVersion configurations.

Mini-Challenge:

Imagine you have a utility-library micro-frontend that exposes a formatDate function.

  1. Create remote-app-3 that exposes this function.
  2. Make sure host-app consumes this formatDate function and displays the formatted date.
  3. Ensure remote-app-1 also consumes and uses this formatDate function if it needs to display a date.
  4. How would you configure shared to make sure moment.js (if used by formatDate) is only loaded once?

3. Intermediate Topics

Now that you have a solid grasp of the foundational concepts, let’s explore some intermediate topics that enhance the flexibility and robustness of your Module Federation applications.

3.1 Dynamic Remote Loading and Entrypoint Configuration

In our initial setup, the remotes configuration in the host-app was static, meaning the host knew exactly where remoteApp1’s remoteEntry.js file was located at build time. While this works for simple cases, real-world micro-frontends often require more flexibility:

  • Dynamic URLs: Remote applications might be deployed to different environments (dev, staging, prod) with varying URLs.
  • Feature Flags: You might want to load different remotes based on feature flags or user permissions.
  • API-driven Remotes: The list of available remotes and their entry points could come from a backend API.

Module Federation supports dynamic remote loading to handle these scenarios. Instead of a static string for the remote’s entry point, you can provide a function that returns a Promise resolving to the remote’s location.

Detailed Explanation:

1. Function as Remote Value:

You can specify a function that returns a Promise to the remote’s entry point.

// host-app/webpack.config.js
// ...
plugins: [
  new ModuleFederationPlugin({
    name: 'hostApp',
    remotes: {
      remoteApp1: `promise new Promise(resolve => {
        const urlParams = new URLSearchParams(window.location.search);
        const remoteUrl = urlParams.get('remoteApp1Url') || 'http://localhost:3001/remoteEntry.js';
        resolve('remoteApp1@' + remoteUrl);
      })`,
      // Or from an environment variable
      // remoteApp2: `remoteApp2@\${process.env.REMOTE_APP_2_URL}/remoteEntry.js`,
    },
    // ...
  }),
],
// ...

In this example:

  • The remoteApp1 entry is now a promise that resolves to the actual remote URL.
  • It checks for a remoteApp1Url query parameter, otherwise defaults to http://localhost:3001/remoteEntry.js. This allows you to dynamically control which version or instance of remoteApp1 the host loads by simply changing the URL in the browser (e.g., http://localhost:3000/?remoteApp1Url=http://staging.remoteapp1.com/remoteEntry.js).

2. Asynchronous Remote Loading (Runtime):

For even greater flexibility, especially if you need to fetch remote URLs from an API, you can define an empty remotes object and dynamically load remotes at runtime using a helper function.

First, define an empty remotes in webpack.config.js:

// host-app/webpack.config.js
// ...
plugins: [
  new ModuleFederationPlugin({
    name: 'hostApp',
    remotes: {}, // Empty remotes
    shared: { /* ... */ },
  }),
  // ...
],
// ...

Then, in your host application’s JavaScript, you can programmatically load remotes:

// host-app/src/App.tsx
import React, { Suspense, lazy, useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';

// This function needs to be globally available or part of your build process
// In a real app, this might come from a configuration service or a shared utility.
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
declare const __webpack_share_scopes__: any;
declare const __webpack_remotes__: any;

interface RemoteConfig {
  name: string;
  url: string;
  scope: string;
  module: string;
}

const loadRemoteModule = async (remoteConfig: RemoteConfig) => {
  if (!window[remoteConfig.scope]) {
    await __webpack_init_sharing__('default'); // Initialize sharing
    const container = new Promise<any>((resolve, reject) => {
      const script = document.createElement('script');
      script.src = remoteConfig.url;
      script.onload = () => {
        script.remove();
        // @ts-ignore
        if (!window[remoteConfig.scope]) { // Ensure the global scope is present
            reject(new Error(`Failed to load remote: ${remoteConfig.name} - global scope not found.`));
            return;
        }
        // @ts-ignore
        window[remoteConfig.scope].init(__webpack_share_scopes__.default)
          .then(() => resolve(window[remoteConfig.scope]))
          .catch(reject);
      };
      script.onerror = reject;
      document.head.appendChild(script);
    });
    window[remoteConfig.scope] = await container;
  }
  // @ts-ignore
  const moduleFactory = await window[remoteConfig.scope].get(remoteConfig.module);
  return moduleFactory();
};

const App = () => {
  const [remoteButtonComponent, setRemoteButtonComponent] = useState<React.ComponentType | null>(null);

  useEffect(() => {
    const fetchRemoteConfig = async () => {
      // In a real app, this could be an API call
      const remoteConfigs: RemoteConfig[] = [
        {
          name: 'remoteApp1',
          url: 'http://localhost:3001/remoteEntry.js',
          scope: 'remoteApp1', // This must match the 'name' in remote's ModuleFederationPlugin
          module: './MyButton',
        },
        // Add other remotes dynamically
      ];

      try {
        const buttonModule = await loadRemoteModule(remoteConfigs[0]);
        setRemoteButtonComponent(() => buttonModule.default); // Assuming it's a default export
      } catch (error) {
        console.error('Failed to load remote component:', error);
        // Handle error, e.g., display a fallback UI
      }
    };
    fetchRemoteConfig();
  }, []);

  const RemoteButton = remoteButtonComponent
    ? lazy(() => Promise.resolve({ default: remoteButtonComponent }))
    : null;

  return (
    <div>
      <h1>Host Application</h1>
      <h2>Loading Remote Component Dynamically:</h2>
      {RemoteButton ? (
        <Suspense fallback={<div>Loading Dynamic Remote Button...</div>}>
          <RemoteButton />
        </Suspense>
      ) : (
        <div>Failed to load remote button or loading...</div>
      )}
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

This is a more advanced pattern and involves a deeper understanding of the Webpack runtime. The window[remoteConfig.scope].init(__webpack_share_scopes__.default) part is crucial for making sure shared modules are correctly initialized and reused.

Exercise 3.1: Implement Dynamic Remote Loading with Environment Variables

  1. In host-app/webpack.config.js, modify the remoteApp1 entry to use an environment variable.
    // host-app/webpack.config.js
    // ...
    plugins: [
      new ModuleFederationPlugin({
        name: 'hostApp',
        remotes: {
          remoteApp1: `remoteApp1@\${process.env.REMOTE_APP_1_URL || 'http://localhost:3001/remoteEntry.js'}`,
        },
        // ...
      }),
    ],
    // ...
    
  2. Add a DefinePlugin to your host-app/webpack.config.js to inject environment variables:
    // host-app/webpack.config.js
    // ...
    const webpack = require('webpack'); // Add this line
    // ...
    plugins: [
        // ...
        new webpack.DefinePlugin({
            'process.env.REMOTE_APP_1_URL': JSON.stringify(process.env.REMOTE_APP_1_URL),
        }),
    ],
    // ...
    
  3. Modify your host-app/package.json start script to pass an environment variable:
    // host-app/package.json
    "scripts": {
      "start": "REMOTE_APP_1_URL=http://localhost:3001/remoteEntry.js webpack serve --open"
    },
    
    (Note: For cross-platform compatibility, consider using cross-env package: npm install --save-dev cross-env and then cross-env REMOTE_APP_1_URL=... webpack serve --open)
  4. Run remote-app-1 on port 3001.
  5. Run host-app. Verify it still loads remoteApp1.
  6. Try running host-app without the REMOTE_APP_1_URL environment variable. It should fall back to the default http://localhost:3001/remoteEntry.js.

3.2 Bidirectional Hosting

Module Federation is not limited to a one-way street where a host consumes remotes. Applications can act as both a host and a remote simultaneously. This is called bidirectional hosting.

Detailed Explanation:

Consider a scenario where:

  • App A exposes Component A.
  • App B exposes Component B.
  • App C is a shell that consumes both Component A and Component B.
  • But also, App A might need a utility function exposed by App B.

In such a case, App A would be a remote to App C, but also a host to App B. Similarly, App B would be a remote to App C, and also a remote to App A (if App A consumes something from App B).

Configuration for Bidirectional Hosting:

The setup is straightforward: an application simply includes both remotes and exposes in its ModuleFederationPlugin configuration.

Example Scenario:

  • remote-app-1 (running on http://localhost:3001) exposes MyButton.
  • remote-app-2 (running on http://localhost:3002) exposes MyHeader and needs to consume MyButton from remote-app-1.

1. remote-app-1 (Already set up to expose MyButton):

// remote-app-1/webpack.config.js
// ...
plugins: [
  new ModuleFederationPlugin({
    name: 'remoteApp1',
    filename: 'remoteEntry.js',
    exposes: {
      './MyButton': './src/MyButton',
    },
    shared: { /* ... */ },
  }),
],
// ...

2. remote-app-2 (Will act as both remote and host):

cd module-federation-tutorial/packages
mkdir remote-app-2
cd remote-app-2
npm init -y
npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server

Create remote-app-2/src/index.tsx:

// remote-app-2/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyHeader from './MyHeader';

const App = () => {
  return (
    <div>
      <MyHeader />
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

Create remote-app-2/src/MyHeader.tsx (exposes this):

// remote-app-2/src/MyHeader.tsx
import React, { lazy, Suspense } from 'react';

// This remote will also consume a component from remoteApp1
const RemoteButton = lazy(() => import('remoteApp1/MyButton'));

const MyHeader: React.FC = () => {
  return (
    <div style={{ padding: '15px', backgroundColor: '#f0f0f0', borderBottom: '1px solid #ccc' }}>
      <h3>Header from Remote App 2</h3>
      <p>This header also consumes a button from Remote App 1:</p>
      <Suspense fallback={<div>Loading Remote Button in Header...</div>}>
        <RemoteButton />
      </Suspense>
    </div>
  );
};

export default MyHeader;

Create remote-app-2/public/index.html:

<!-- remote-app-2/public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Remote App 2</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

Create remote-app-2/webpack.config.js:

// remote-app-2/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  entry: './src/index.tsx',
  mode: 'development',
  devServer: {
    port: 3002, // Different port
    historyApiFallback: true,
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react', '@babel/preset-typescript'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remoteApp2',
      filename: 'remoteEntry.js',
      exposes: {
        './MyHeader': './src/MyHeader', // Exposing MyHeader
      },
      remotes: {
        remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js', // Consuming RemoteApp1
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
  output: {
    publicPath: 'http://localhost:3002/',
  },
};

Add start script to remote-app-2/package.json:

// remote-app-2/package.json
{
  "name": "remote-app-2",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --open"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@babel/preset-react": "^7.20.0",
    "@babel/preset-typescript": "^7.20.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@types/webpack": "^5.28.0",
    "@types/webpack-dev-server": "^4.7.0",
    "babel-loader": "^9.1.0",
    "html-webpack-plugin": "^5.5.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "typescript": "^5.0.0",
    "webpack": "^5.75.0",
    "webpack-cli": "^5.0.0",
    "webpack-dev-server": "^4.11.0"
  }
}

3. Update host-app to consume MyHeader from remote-app-2:

// host-app/webpack.config.js
// ...
plugins: [
    new ModuleFederationPlugin({
      name: 'hostApp',
      remotes: {
        remoteApp1: 'remoteApp1@http://localhost:3001/remoteEntry.js',
        remoteApp2: 'remoteApp2@http://localhost:3002/remoteEntry.js', // Add remoteApp2
      },
      shared: { /* ... */ },
    }),
    // ...
  ],
// ...
// host-app/src/index.tsx
import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';

const RemoteButton = lazy(() => import('remoteApp1/MyButton'));
const RemoteHeader = lazy(() => import('remoteApp2/MyHeader')); // Import RemoteHeader

const App = () => {
  return (
    <div>
      <h1>Host Application</h1>
      <h2>Component from Remote App 1:</h2>
      <Suspense fallback={<div>Loading Remote Button...</div>}>
        <RemoteButton />
      </Suspense>

      <h2>Component from Remote App 2 (which also uses Remote App 1's Button):</h2>
      <Suspense fallback={<div>Loading Remote Header...</div>}>
        <RemoteHeader />
      </Suspense>
    </div>
  );
};

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<App />);

Running Bidirectional Hosting Example:

  1. Open three terminal windows.
  2. Terminal 1 (for remote-app-1):
    cd module-federation-tutorial/packages/remote-app-1
    npm start
    
    (Runs on http://localhost:3001)
  3. Terminal 2 (for remote-app-2):
    cd module-federation-tutorial/packages/remote-app-2
    npm start
    
    (Runs on http://localhost:3002. You’ll see “Header from Remote App 2” and inside it, “Click Me (Remote 1 Button)” button. This confirms remote-app-2 is consuming remote-app-1’s button).
  4. Terminal 3 (for host-app):
    cd module-federation-tutorial/packages/host-app
    npm start
    
    (Runs on http://localhost:3000. You’ll see “Host Application”, “Component from Remote App 1:” with the button, and “Component from Remote App 2…” with the header, which itself contains the button. This demonstrates host-app consuming both remotes, and remote-app-2 consuming remote-app-1).

This setup elegantly demonstrates how different parts of your application can interact in a distributed fashion. The shared dependencies (React, React-DOM) are still managed efficiently, ensuring only one instance is loaded.

Mini-Challenge:

Modify remote-app-1 to consume a “Footer” component exposed by remote-app-2. How would you update the remote-app-1/webpack.config.js and remote-app-1/src/index.tsx files to achieve this?

3.3 Versioning Strategies

Managing dependencies and their versions across multiple independent micro-frontends is one of the more complex aspects of Module Federation. Without careful planning, version conflicts can lead to broken applications or unexpected behavior. Module Federation provides tools to help manage this.

Detailed Explanation:

We’ve already touched upon requiredVersion and singleton in the shared configuration. Let’s delve deeper into how these and other strategies help.

1. requiredVersion and singleton:

  • requiredVersion: '^18.2.0': This tells Module Federation that the application (host or remote) is compatible with any version of the dependency starting from 18.2.0 up to 19.0.0 (but not including).
  • singleton: true: When true, Module Federation attempts to load only one instance of this module. It prefers the host’s version if compatible. If the host doesn’t provide it, or if its version isn’t compatible, it will try to find a compatible version among the remotes. If no compatible version can be found and strictVersion is not true, it might load multiple versions as a fallback.

2. strictVersion: true (for shared dependencies):

  • When strictVersion: true is combined with requiredVersion, Module Federation becomes very strict. If a host and a remote declare conflicting requiredVersion ranges that have no overlap, or if a compatible singleton cannot be established, the build (or runtime) will fail. This is useful for preventing hard-to-debug runtime issues but requires careful coordination between teams.

    // shared with strict versioning
    shared: {
      react: {
        singleton: true,
        requiredVersion: '^18.2.0',
        strictVersion: true, // Will fail if incompatible version is loaded
      },
    }
    

3. version (for shared dependencies):

  • If you explicitly want a specific application to provide a certain version for sharing, you can use version. This is less commonly used than requiredVersion but can be useful in specific scenarios.
    // host-app providing a specific version
    shared: {
      react: {
        singleton: true,
        version: '18.2.0', // Host explicitly provides 18.2.0
      },
    }
    

4. Fallbacks:

  • Module Federation supports fallbacks when a remote module cannot be loaded (e.g., due to network issues, a non-existent remote, or version incompatibility). You can define a local fallback module that will be used instead. This adds resilience to your application.

    // Example in host-app's ModuleFederationPlugin remotes config
    remotes: {
      remoteApp1: `remoteApp1@http://localhost:3001/remoteEntry.js || ./src/fallbacks/RemoteApp1Fallback`,
    },
    

    And then create host-app/src/fallbacks/RemoteApp1Fallback.tsx:

    // host-app/src/fallbacks/RemoteApp1Fallback.tsx
    import React from 'react';
    
    const RemoteApp1Fallback: React.FC = () => {
      return (
        <div style={{ border: '1px solid red', padding: '10px', color: 'red' }}>
          Error: Could not load Remote App 1. Displaying fallback UI.
        </div>
      );
    };
    
    export default RemoteApp1Fallback;
    

    Now, if http://localhost:3001/remoteEntry.js is unreachable or fails to load, the fallback component will be used.

5. Semantic Versioning and Communication:

  • Beyond technical configurations, effective communication and adherence to semantic versioning (SemVer) are paramount for successful micro-frontend development.
    • SemVer: Micro-frontend teams should clearly communicate breaking changes (major version bumps), new features (minor version bumps), and bug fixes (patch version bumps).
    • API Contracts: Define clear API contracts for exposed modules (inputs, outputs, events) to minimize unexpected breaking changes.
    • Shared Design System: A unified design system helps ensure visual consistency and provides a common language for UI components, reducing the need to share every granular component via federation.

Exercise 3.3: Implement Fallback UI

  1. Modify host-app/webpack.config.js to include a fallback for remoteApp1.
    // host-app/webpack.config.js
    // ...
    plugins: [
        new ModuleFederationPlugin({
            name: 'hostApp',
            remotes: {
                remoteApp1: `remoteApp1@http://localhost:3001/remoteEntry.js || ./src/fallbacks/RemoteApp1Fallback`,
                remoteApp2: 'remoteApp2@http://localhost:3002/remoteEntry.js',
            },
            shared: { /* ... */ },
        }),
        // ...
    ],
    // ...
    
  2. Create the host-app/src/fallbacks directory and RemoteApp1Fallback.tsx file as described above.
  3. Start host-app and remote-app-2. Do NOT start remote-app-1.
  4. Navigate to http://localhost:3000. You should see the fallback UI for remoteApp1 and the MyHeader from remoteApp2. This demonstrates graceful degradation.

Mini-Challenge:

How would you implement a mechanism to allow a remote application to prefer its own version of a shared library (e.g., lodash) if the host’s version is too old, but still use the host’s newer version if available? (Hint: look into shareConfig options like version and import).

4. Advanced Topics and Best Practices

Having covered the fundamentals and intermediate concepts, let’s explore more advanced aspects of Module Federation, focusing on real-world considerations, performance, and best practices.

4.1 Advanced Configuration with Different Environments

Deploying micro-frontends involves managing different environments (development, staging, production), each often having unique URLs for remote entry points. We touched on dynamic remote loading with environment variables, but let’s formalize this with dedicated configuration.

Detailed Explanation:

The most common approach is to use environment variables or a configuration file that is specific to each environment.

1. Using Environment Variables and webpack.DefinePlugin (Revisited): This is a standard Webpack practice to inject global variables into your client-side code.

// webpack.config.js (for host or remote)
const webpack = require('webpack');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = (env, argv) => {
  const isProduction = argv.mode === 'production';

  return {
    // ... other webpack config
    plugins: [
      new ModuleFederationPlugin({
        name: 'hostApp',
        remotes: {
          remoteApp1: `remoteApp1@\${isProduction ? 'https://prod.remoteapp1.com' : 'http://localhost:3001'}/remoteEntry.js`,
          // Or from a specific env variable
          remoteApp2: `remoteApp2@\${process.env.REMOTE_APP_2_URL || 'http://localhost:3002'}/remoteEntry.js`,
        },
        shared: { /* ... */ },
      }),
      new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(argv.mode),
        'process.env.REMOTE_APP_2_URL': JSON.stringify(process.env.REMOTE_APP_2_URL), // Inject specific env var
      }),
      // ... other plugins
    ],
    // ...
  };
};
  • argv.mode gives you the current webpack mode (development, production).
  • process.env.REMOTE_APP_X_URL allows you to set specific URLs via command line for different builds.
    • Example for package.json scripts:
      "scripts": {
        "start": "webpack serve --mode development",
        "build:dev": "REMOTE_APP_2_URL=http://staging-remote2.com webpack build --mode development",
        "build:prod": "REMOTE_APP_2_URL=https://prod-remote2.com webpack build --mode production"
      }
      

2. Centralized Configuration Service: For very large micro-frontend ecosystems, storing remote URLs and other configurations in a central configuration service (e.g., a simple JSON hosted on a CDN, or a more sophisticated backend service) is a robust solution.

  • The host application would fetch this configuration at runtime.

  • This approach uses the dynamic remote loading technique discussed in section 3.1.

    // host-app/src/bootstrap.tsx (or similar entry file)
    import { startApp } from './app';
    
    async function loadConfigAndStartApp() {
      try {
        const configResponse = await fetch('/api/microfrontend-config'); // Fetch from your backend
        const config = await configResponse.json();
    
        // Dynamically configure remotes based on fetched config
        // This is pseudo-code as direct modification of ModuleFederationPlugin at runtime is not straightforward
        // Instead, the `loadRemoteModule` function from 3.1 would use this config.
    
        // Example: If config structure is { remoteApp1: { url: "...", scope: "..." } }
        // Pass this config to a context provider or a global state for your app components to use.
        startApp(config);
    
      } catch (error) {
        console.error('Failed to load micro-frontend config:', error);
        // Render a full fallback page if critical config cannot be loaded
      }
    }
    
    loadConfigAndStartApp();
    

4.2 Performance Considerations and Optimization Strategies

While Module Federation offers many benefits, improper implementation can lead to performance bottlenecks. Optimizing your federated applications is crucial.

Detailed Explanation:

1. Lazy Loading (Default and Explicit):

  • Module Federation is lazy by default: Remote modules are not downloaded until they are actually imported and used. This is a massive performance win.

  • React.lazy and Suspense: Always wrap your dynamically imported React components with lazy and Suspense in your host application. This ensures that the component’s code is fetched only when it’s rendered, and provides a smooth user experience with loading indicators.

    import { lazy, Suspense } from 'react';
    const RemoteComponent = lazy(() => import('remoteApp/Component'));
    
    function App() {
      return (
        <Suspense fallback={<div>Loading Remote Component...</div>}>
          <RemoteComponent />
        </Suspense>
      );
    }
    

2. Effective Shared Dependency Management:

  • singleton: true: As discussed, this is critical to prevent multiple downloads and instances of core libraries like React, React-DOM, Redux, etc.
  • requiredVersion: Use this to define compatible version ranges.
  • Avoid eager: true unless necessary: Eagerly loaded shared modules are bundled with the entry point, increasing initial load time. Only use it for critical dependencies that must be available immediately.
  • Minimize shared dependencies: Only share libraries that are truly common and large. For small, application-specific utilities, it might be better for each remote to bundle its own copy.

3. Code Splitting:

  • Beyond Module Federation’s lazy loading, apply standard Webpack code-splitting techniques within your individual host and remote applications. This ensures that even within a single micro-frontend, code is split into smaller chunks and loaded on demand (e.g., routing-based code splitting).

4. Caching Strategies:

  • Long-term caching for remoteEntry.js and federated chunks: Ensure your server/CDN serves these files with appropriate Cache-Control headers (e.g., max-age=31536000, immutable).
  • Cache busting: Module Federation often generates content hashes in filenames (remoteEntry.js becomes remoteEntry.[hash].js in production builds) which automatically handle cache busting. For remoteEntry.js itself, ensure its URL changes if the exposed modules change. You might need to configure your CI/CD to update the remoteEntry.js path in the host’s configuration when a remote is updated.
  • Manifest Protocol (Module Federation 2.0): The mf-manifest.json file in MF 2.0 can simplify deployment platform integration and management of version resources and gray releases. This manifest provides metadata about the federated application.

5. HTTP/2 and CDNs:

  • Deploy your micro-frontends to a Content Delivery Network (CDN) to reduce latency.
  • Leverage HTTP/2 for efficient parallel loading of multiple smaller chunks, which is common in federated applications.

6. Build Optimization:

  • Tree Shaking: Ensure your Webpack configuration is set up for tree-shaking to remove unused code from bundles.
  • Minification & Compression: Always minify and compress your bundles (gzip, brotli) for production.

7. Monitoring and Observability:

  • Implement robust monitoring for each micro-frontend to track performance metrics, error rates, and resource usage. Tools like Webpack Bundle Analyzer can help identify large bundles or shared dependencies that are not being efficiently deduplicated.

4.3 Common Pitfalls and Debugging Techniques

Module Federation can introduce new complexities. Being aware of common pitfalls and having debugging strategies is essential.

Detailed Explanation:

Common Pitfalls:

  1. Version Mismatches (Silent Failures or Runtime Errors):

    • Problem: If shared dependencies are not configured correctly or singleton isn’t used for critical libraries, you might end up with multiple versions of the same library loaded. This can lead to cryptic errors, especially with React context or hooks.
    • Solution: Use singleton: true for frameworks and critical libraries. Be precise with requiredVersion. Use strictVersion: true in development to catch issues early. Establish clear communication between teams about shared dependency versions.
  2. publicPath Issues:

    • Problem: Incorrect output.publicPath in webpack.config.js (especially for remotes) can lead to bundles not being found (404 errors in the network tab). The publicPath must be an absolute URL where the application’s assets are hosted.
    • Solution: Double-check that publicPath is correctly configured for each application, including the trailing slash (e.g., http://localhost:3001/).
  3. Missing remoteEntry.js or Exposed Modules:

    • Problem: If the host tries to load a remote that is not running, or if the remoteEntry.js file is not accessible, or if an exposed module name is misspelled.
    • Solution: Verify that all remote applications are running on their configured ports. Check the network tab for 404s on remoteEntry.js or other federated chunks. Carefully inspect remotes and exposes names for typos. Implement fallbacks as discussed earlier.
  4. CSS Collisions/Global Styles:

    • Problem: Micro-frontends often use different CSS methodologies. If not scoped properly, styles from one remote can unintentionally affect others or the host.
    • Solution: Encourage isolated styling: CSS Modules, scoped CSS-in-JS (e.g., Emotion with unique class names), utility-first frameworks with explicit prefixes (e.g., Tailwind CSS with prefix), or Web Components using Shadow DOM. A strong, unified design system implemented as a shared dependency can also mitigate this.
  5. Performance Overheads (Initial Load Time, Waterfall Requests):

    • Problem: Too many remotes, large shared bundles loaded eagerly, or inefficient network requests can slow down the application.
    • Solution: Lazy load everything possible. Optimize shared dependencies. Leverage HTTP/2. Use Webpack Bundle Analyzer to identify large chunks.

Debugging Techniques:

  1. Browser Developer Tools (Network Tab):

    • Key tool: Monitor network requests. Look for 404 errors, observe which remoteEntry.js files are loaded, and check if shared dependencies are loaded once (and which application loaded them).
    • Resource Timing: Analyze the timing of module loads to identify bottlenecks.
  2. Console Logs:

    • Add console.log statements in your host and remote applications to trace execution flow and verify that components are being loaded and rendered correctly.
    • Module Federation often provides useful warnings or errors in the console regarding shared dependency mismatches.
  3. Webpack Stats and Bundle Analyzer:

    • Generate Webpack stats (e.g., webpack --json > stats.json).
    • Use webpack-bundle-analyzer (npm install --save-dev webpack-bundle-analyzer) to visualize your bundle composition, including shared modules and remote chunks. This helps identify large dependencies or inefficient sharing.
    webpack --profile --json > stats.json
    webpack-bundle-analyzer stats.json
    
  4. Chrome DevTools Extension (for Module Federation 2.0):

    • Module Federation 2.0 comes with enhanced debugging tools, including a new Chrome DevTools extension. This can display dependencies between modules, exposed and shared configurations, and aid in debugging. Look for this in the Chrome Web Store.
  5. window.__FEDERATION__ (Development Tool):

    • In development, you can often inspect window.__FEDERATION__ in your browser’s console to see runtime information about loaded remotes and shared modules. This provides insights into how Module Federation is resolving dependencies.
  6. Error Boundaries (React-specific):

    • Wrap dynamically loaded remote components with React Error Boundaries. This prevents a crashing remote from bringing down the entire host application, isolating failures and providing a user-friendly fallback.
    class ErrorBoundary extends React.Component {
      state = { hasError: false };
      static getDerivedStateFromError(error: any) {
        return { hasError: true };
      }
      componentDidCatch(error: any, errorInfo: any) {
        console.error('ErrorBoundary caught an error:', error, errorInfo);
      }
      render() {
        if (this.state.hasError) {
          return <h1>Something went wrong loading this component.</h1>;
        }
        return this.props.children;
      }
    }
    
    // In your App:
    <Suspense fallback={<div>Loading...</div>}>
      <ErrorBoundary>
        <RemoteComponent />
      </ErrorBoundary>
    </Suspense>
    

Exercise 4.1: Simulate a Remote App Failure and Observe Fallback/Error Boundary

  1. Ensure your host-app has the fallback configured for remoteApp1 (from Exercise 3.3).
  2. Also, wrap the <RemoteButton /> component in host-app/src/index.tsx with the ErrorBoundary component shown above.
  3. Modify remote-app-1/src/MyButton.tsx to intentionally throw an error (e.g., throw new Error('Intentional error in remote button!');).
  4. Start remote-app-1 and remote-app-2.
  5. Start host-app.
  6. Observe http://localhost:3000. What happens? Do you see the fallback UI, the error boundary message, or both? How does this impact the loading of remote-app-2’s header? Analyze the console output.
  7. Now, stop remote-app-1. What happens to the host? Does the fallback take over?

5. Guided Projects

Learning by doing is the best way to master Module Federation. These projects will guide you through building practical micro-frontend applications.

Project 1: An E-commerce Product Listing Micro-Frontend

Objective: Build a simplified e-commerce platform where a central “Shell” application hosts a “Product List” micro-frontend and a “Shopping Cart” micro-frontend.

Technologies: React, Webpack 5, Module Federation.

Project Structure:

module-federation-tutorial/
├── packages/
│   ├── host-app/            (Shell application, port 3000)
│   ├── products-app/        (Remote: Displays product list, port 3001)
│   └── cart-app/            (Remote: Manages shopping cart, port 3002)
├── package.json             (Monorepo root package.json)

Step-by-Step Guide:

Phase 1: Setup Host Application (host-app)

  1. Navigate to packages:
    cd module-federation-tutorial/packages
    
  2. Create host-app: If you haven’t already, follow the “Setting up your first Host and Remote” section (starting from “Revisiting Setup: Minimal Webpack Project”) to create host-app. Ensure it’s on port: 3000.
  3. Update host-app/src/index.tsx: Make it a basic shell for now.
    // host-app/src/index.tsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    
    const App = () => {
      return (
        <div>
          <nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between' }}>
            <h1 style={{ margin: 0 }}>E-commerce Host</h1>
            <div>
              <a href="#" style={{ color: 'white', marginRight: '15px' }}>Home</a>
              <a href="#" style={{ color: 'white' }}>Cart (0)</a> {/* This will be updated by Cart App later */}
            </div>
          </nav>
          <div style={{ padding: '20px' }}>
            {/* Products App will go here */}
            {/* Cart App will go here */}
          </div>
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  4. host-app/webpack.config.js: Configure ModuleFederationPlugin with a name, and shared React dependencies. Leave remotes empty for now.
    // host-app/webpack.config.js
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const { ModuleFederationPlugin } = require('webpack').container;
    const path = require('path');
    
    module.exports = {
      entry: './src/index.tsx',
      mode: 'development',
      devServer: {
        port: 3000,
        historyApiFallback: true,
      },
      resolve: {
        extensions: ['.tsx', '.ts', '.js'],
      },
      module: {
        rules: [
          {
            test: /\.(js|jsx|ts|tsx)$/,
            exclude: /node_modules/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-react', '@babel/preset-typescript'],
              },
            },
          },
        ],
      },
      plugins: [
        new ModuleFederationPlugin({
          name: 'hostApp',
          remotes: {
            // Will add productsApp and cartApp here
          },
          shared: {
            react: { singleton: true, requiredVersion: '^18.2.0' },
            'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
          },
        }),
        new HtmlWebpackPlugin({
          template: './public/index.html',
        }),
      ],
      output: {
        publicPath: 'http://localhost:3000/',
      },
    };
    
  5. Test host-app: Run cd host-app && npm start. You should see the basic E-commerce Host navigation bar.

Phase 2: Create Product List Micro-Frontend (products-app)

  1. Create products-app:
    cd module-federation-tutorial/packages
    mkdir products-app
    cd products-app
    npm init -y
    npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
    npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server
    
  2. products-app/public/index.html:
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Products App</title>
    </head>
    <body>
        <div id="root"></div>
    </body>
    </html>
    
  3. Create products-app/src/ProductList.tsx: This component will display a list of dummy products.
    // products-app/src/ProductList.tsx
    import React from 'react';
    
    interface Product {
      id: number;
      name: string;
      price: number;
    }
    
    const products: Product[] = [
      { id: 1, name: 'Laptop', price: 1200 },
      { id: 2, name: 'Mouse', price: 25 },
      { id: 3, name: 'Keyboard', price: 75 },
    ];
    
    const ProductList: React.FC = () => {
      return (
        <div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
          <h2>Products Available</h2>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {products.map(product => (
              <li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <span>{product.name} - ${product.price}</span>
                <button style={{ padding: '8px 12px', backgroundColor: 'green', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
                  Add to Cart
                </button>
              </li>
            ))}
          </ul>
        </div>
      );
    };
    
    export default ProductList;
    
  4. products-app/src/index.tsx:
    // products-app/src/index.tsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import ProductList from './ProductList';
    
    const App = () => {
      return (
        <div>
          <ProductList />
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  5. products-app/webpack.config.js: Configure it as a remote, exposing ProductList on port 3001.
    // products-app/webpack.config.js
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const { ModuleFederationPlugin } = require('webpack').container;
    const path = require('path');
    
    module.exports = {
      entry: './src/index.tsx',
      mode: 'development',
      devServer: {
        port: 3001,
        historyApiFallback: true,
      },
      resolve: {
        extensions: ['.tsx', '.ts', '.js'],
      },
      module: {
        rules: [
          {
            test: /\.(js|jsx|ts|tsx)$/,
            exclude: /node_modules/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-react', '@babel/preset-typescript'],
              },
            },
          },
        ],
      },
      plugins: [
        new ModuleFederationPlugin({
          name: 'productsApp',
          filename: 'remoteEntry.js',
          exposes: {
            './ProductList': './src/ProductList', // Expose ProductList
          },
          shared: {
            react: { singleton: true, requiredVersion: '^18.2.0' },
            'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
          },
        }),
        new HtmlWebpackPlugin({
          template: './public/index.html',
        }),
      ],
      output: {
        publicPath: 'http://localhost:3001/',
      },
    };
    
  6. Test products-app: Run cd products-app && npm start. You should see the “Products Available” list on http://localhost:3001.

Phase 3: Integrate Product List into Host

  1. Update host-app/webpack.config.js: Add productsApp to remotes.
    // host-app/webpack.config.js
    // ...
    plugins: [
        new ModuleFederationPlugin({
          name: 'hostApp',
          remotes: {
            productsApp: 'productsApp@http://localhost:3001/remoteEntry.js', // Add productsApp
          },
          // ...
        }),
        // ...
      ],
    // ...
    
  2. Update host-app/src/index.tsx: Dynamically import and render ProductList.
    // host-app/src/index.tsx
    import React, { Suspense, lazy } from 'react';
    import ReactDOM from 'react-dom/client';
    
    const RemoteProductList = lazy(() => import('productsApp/ProductList'));
    
    const App = () => {
      return (
        <div>
          <nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between' }}>
            <h1 style={{ margin: 0 }}>E-commerce Host</h1>
            <div>
              <a href="#" style={{ color: 'white', marginRight: '15px' }}>Home</a>
              <a href="#" style={{ color: 'white' }}>Cart (0)</a>
            </div>
          </nav>
          <div style={{ padding: '20px' }}>
            <h2>Welcome to our Store!</h2>
            <Suspense fallback={<div>Loading Products...</div>}>
              <RemoteProductList />
            </Suspense>
          </div>
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  3. Run and Verify:
    • Open Terminal 1: cd packages/products-app && npm start
    • Open Terminal 2: cd packages/host-app && npm start
    • Navigate to http://localhost:3000. You should now see the ProductList micro-frontend rendered within the host-app!

Phase 4: Create Shopping Cart Micro-Frontend (cart-app) and Inter-Micro-Frontend Communication

This phase will introduce a cart-app and demonstrate how micro-frontends can communicate.

  1. Create cart-app:

    cd module-federation-tutorial/packages
    mkdir cart-app
    cd cart-app
    npm init -y
    npm install react react-dom webpack webpack-cli webpack-dev-server html-webpack-plugin babel-loader @babel/core @babel/preset-react --save-dev
    npm install --save-dev typescript @types/react @types/react-dom @types/webpack @types/webpack-dev-server
    
  2. cart-app/public/index.html: Similar to products-app.

  3. cart-app/src/Cart.tsx: This component will manage cart state. It will expose a way for other apps to “add to cart” and will also expose its display component.

    // cart-app/src/Cart.tsx
    import React, { useState, useEffect } from 'react';
    
    interface CartItem {
      id: number;
      name: string;
      price: number;
      quantity: number;
    }
    
    // A simple global event emitter for cross-microfrontend communication
    const eventEmitter = new EventTarget();
    
    export const addToCart = (product: { id: number; name: string; price: number }) => {
      const event = new CustomEvent('add-to-cart', { detail: product });
      eventEmitter.dispatchEvent(event);
    };
    
    export const CartDisplay: React.FC = () => {
      const [cartItems, setCartItems] = useState<CartItem[]>([]);
    
      useEffect(() => {
        const handleAddToCart = (event: Event) => {
          const product = (event as CustomEvent).detail;
          setCartItems(prevItems => {
            const existingItem = prevItems.find(item => item.id === product.id);
            if (existingItem) {
              return prevItems.map(item =>
                item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
              );
            }
            return [...prevItems, { ...product, quantity: 1 }];
          });
        };
    
        eventEmitter.addEventListener('add-to-cart', handleAddToCart as EventListener);
        return () => {
          eventEmitter.removeEventListener('add-to-cart', handleAddToCart as EventListener);
        };
      }, []);
    
      const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0);
      const totalPrice = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    
      // This could also update a global state or emit an event for the host to update its cart count
      useEffect(() => {
        const cartUpdateEvent = new CustomEvent('cart-updated', { detail: { totalItems } });
        window.dispatchEvent(cartUpdateEvent);
      }, [totalItems]);
    
    
      return (
        <div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', backgroundColor: '#f9f9f9', marginTop: '20px' }}>
          <h3>Your Shopping Cart ({totalItems} items)</h3>
          {cartItems.length === 0 ? (
            <p>Cart is empty.</p>
          ) : (
            <ul style={{ listStyle: 'none', padding: 0 }}>
              {cartItems.map(item => (
                <li key={item.id} style={{ marginBottom: '5px' }}>
                  {item.name} x {item.quantity} - ${item.price * item.quantity}
                </li>
              ))}
            </ul>
          )}
          <p style={{ fontWeight: 'bold' }}>Total: ${totalPrice.toFixed(2)}</p>
          <button style={{ padding: '10px 15px', backgroundColor: 'purple', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
            Checkout
          </button>
        </div>
      );
    };
    
    // We export both for different consumption patterns
    export default CartDisplay;
    
    • Encourage Independent Problem Solving: How would you modify products-app’s “Add to Cart” button to call the addToCart function exposed by cart-app? Think about how you’d import it.
  4. cart-app/src/index.tsx:

    // cart-app/src/index.tsx
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import { CartDisplay } from './Cart';
    
    const App = () => {
      return (
        <div>
          <CartDisplay />
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  5. cart-app/webpack.config.js: Configure it as a remote, exposing CartDisplay and addToCart on port 3002.

    // cart-app/webpack.config.js
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const { ModuleFederationPlugin } = require('webpack').container;
    const path = require('path');
    
    module.exports = {
      entry: './src/index.tsx',
      mode: 'development',
      devServer: {
        port: 3002,
        historyApiFallback: true,
      },
      resolve: {
        extensions: ['.tsx', '.ts', '.js'],
      },
      module: {
        rules: [
          {
            test: /\.(js|jsx|ts|tsx)$/,
            exclude: /node_modules/,
            use: {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-react', '@babel/preset-typescript'],
              },
            },
          },
        ],
      },
      plugins: [
        new ModuleFederationPlugin({
          name: 'cartApp',
          filename: 'remoteEntry.js',
          exposes: {
            './CartDisplay': './src/Cart',   // Expose the CartDisplay component
            './addToCart': './src/Cart',     // Expose the addToCart function
          },
          shared: {
            react: { singleton: true, requiredVersion: '^18.2.0' },
            'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
          },
        }),
        new HtmlWebpackPlugin({
          template: './public/index.html',
        }),
      ],
      output: {
        publicPath: 'http://localhost:3002/',
      },
    };
    
  6. Update host-app/webpack.config.js: Add cartApp to remotes.

    // host-app/webpack.config.js
    // ...
    plugins: [
        new ModuleFederationPlugin({
          name: 'hostApp',
          remotes: {
            productsApp: 'productsApp@http://localhost:3001/remoteEntry.js',
            cartApp: 'cartApp@http://localhost:3002/remoteEntry.js', // Add cartApp
          },
          // ...
        }),
        // ...
      ],
    // ...
    
  7. Update host-app/src/index.tsx: Dynamically import and render CartDisplay and update the cart count in the nav.

    // host-app/src/index.tsx
    import React, { Suspense, lazy, useState, useEffect } from 'react';
    import ReactDOM from 'react-dom/client';
    
    const RemoteProductList = lazy(() => import('productsApp/ProductList'));
    const RemoteCartDisplay = lazy(() => import('cartApp/CartDisplay'));
    
    const App = () => {
      const [cartCount, setCartCount] = useState(0);
    
      useEffect(() => {
        const handleCartUpdate = (event: Event) => {
          const { totalItems } = (event as CustomEvent).detail;
          setCartCount(totalItems);
        };
        window.addEventListener('cart-updated', handleCartUpdate as EventListener);
        return () => {
          window.removeEventListener('cart-updated', handleCartUpdate as EventListener);
        };
      }, []);
    
      return (
        <div>
          <nav style={{ padding: '10px', backgroundColor: '#333', color: 'white', display: 'flex', justifyContent: 'space-between' }}>
            <h1 style={{ margin: 0 }}>E-commerce Host</h1>
            <div>
              <a href="#" style={{ color: 'white', marginRight: '15px' }}>Home</a>
              <a href="#" style={{ color: 'white' }}>Cart ({cartCount})</a>
            </div>
          </nav>
          <div style={{ padding: '20px' }}>
            <h2>Welcome to our Store!</h2>
            <Suspense fallback={<div>Loading Products...</div>}>
              <RemoteProductList />
            </Suspense>
            <Suspense fallback={<div>Loading Cart...</div>}>
              <RemoteCartDisplay />
            </Suspense>
          </div>
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  8. Update products-app/src/ProductList.tsx to use addToCart:

    First, you’ll need to define addToCart type globally or in a declaration file in products-app if you are using TypeScript, since it’s a runtime import. For simplicity in this example, we’ll cast import():

    // products-app/src/ProductList.tsx
    import React from 'react';
    
    interface Product {
      id: number;
      name: string;
      price: number;
    }
    
    const products: Product[] = [
      { id: 1, name: 'Laptop', price: 1200 },
      { id: 2, name: 'Mouse', price: 25 },
      { id: 3, name: 'Keyboard', price: 75 },
    ];
    
    // Dynamically import addToCart function
    const handleAddToCartClick = async (product: Product) => {
      try {
        const { addToCart } = await (import('cartApp/addToCart') as any); // Cast to any for dynamic import
        addToCart(product);
        alert(`${product.name} added to cart!`);
      } catch (error) {
        console.error('Failed to add to cart:', error);
        alert('Could not add item to cart. Cart app might not be available.');
      }
    };
    
    const ProductList: React.FC = () => {
      return (
        <div style={{ border: '1px solid #eee', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
          <h2>Products Available</h2>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {products.map(product => (
              <li key={product.id} style={{ marginBottom: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <span>{product.name} - ${product.price}</span>
                <button
                  style={{ padding: '8px 12px', backgroundColor: 'green', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
                  onClick={() => handleAddToCartClick(product)} // Call the remote function
                >
                  Add to Cart
                </button>
              </li>
            ))}
          </ul>
        </div>
      );
    };
    
    export default ProductList;
    
    • Update products-app/webpack.config.js: Add cartApp to its remotes configuration.
      // products-app/webpack.config.js
      // ...
      plugins: [
          new ModuleFederationPlugin({
            name: 'productsApp',
            filename: 'remoteEntry.js',
            exposes: {
              './ProductList': './src/ProductList',
            },
            remotes: { // products-app needs to consume from cartApp
              cartApp: 'cartApp@http://localhost:3002/remoteEntry.js',
            },
            shared: { /* ... */ },
          }),
          // ...
        ],
      // ...
      
  9. Run All Applications:

    • Open Terminal 1: cd packages/products-app && npm start
    • Open Terminal 2: cd packages/cart-app && npm start
    • Open Terminal 3: cd packages/host-app && npm start
    • Navigate to http://localhost:3000. Now, click “Add to Cart” buttons. The Cart Display should update, and the cart count in the Host navigation should also reflect the changes. This demonstrates communication between a Host and two Remotes, and also between two Remotes (Product App calling Cart App’s addToCart function).

Project 2: A Dashboard with Pluggable Widgets (Advanced)

Objective: Create a dashboard application where users can dynamically add and arrange “widgets” (micro-frontends) from a library of available remotes. This project will heavily utilize dynamic remote loading and potentially explore state management across micro-frontends.

Technologies: React, Webpack 5, Module Federation, potentially a simple state management solution (e.g., React Context or a global event bus).

Project Structure:

module-federation-tutorial/
├── packages/
│   ├── dashboard-shell/     (Host application, port 4000)
│   ├── widget-chart-app/    (Remote: Exposes a chart widget, port 4001)
│   ├── widget-table-app/    (Remote: Exposes a data table widget, port 4002)
│   └── widget-text-app/     (Remote: Exposes a simple text display widget, port 4003)
├── package.json

Step-by-Step Guide (High-Level - Focus on Architecture and Dynamic Loading):

Phase 1: Setup Dashboard Shell (dashboard-shell)

  1. Create dashboard-shell: Similar setup to host-app in Project 1, but on port: 4000.
  2. dashboard-shell/src/index.tsx: This will be more complex, managing a list of active widgets and dynamically rendering them.
    • It will have a state activeWidgets: RemoteWidgetConfig[], where RemoteWidgetConfig might include id, name, remoteName, exposedModule.
    • It will display buttons or a dropdown to “Add Widget”.
    • It will iterate over activeWidgets and dynamically import/render each one.
  3. dashboard-shell/webpack.config.js:
    • name: 'dashboardShell'
    • remotes: {} (empty initially, as we’ll load dynamically)
    • shared: { react: ..., 'react-dom': ... }

Phase 2: Create Widget Micro-Frontends (widget-chart-app, widget-table-app, widget-text-app)

  1. Create each widget app: Follow the remote-app-1 setup pattern for each.

    • widget-chart-app on port: 4001:
      • Exposes ./ChartWidget (a simple placeholder React component).
      • name: 'chartWidgetApp'
    • widget-table-app on port: 4002:
      • Exposes ./TableWidget (a simple placeholder React component).
      • name: 'tableWidgetApp'
    • widget-text-app on port: 4003:
      • Exposes ./TextWidget (a simple placeholder React component).
      • name: 'textWidgetApp'
  2. Ensure each widget’s index.tsx simply renders its main component, and its webpack.config.js exposes that component.

Phase 3: Implement Dynamic Widget Loading in dashboard-shell

  1. Refine dashboard-shell/src/index.tsx:

    • Create a function loadWidget(remoteName: string, exposedModule: string) that dynamically imports the widget using the pattern from Section 3.1 (“Asynchronous Remote Loading (Runtime)”).
    • Maintain a list of available widgets (e.g., { name: 'Chart', remote: 'chartWidgetApp', module: './ChartWidget', url: 'http://localhost:4001/remoteEntry.js' }).
    • Implement an onClick handler for “Add Widget” that calls loadWidget and adds the dynamically loaded component to dashboard-shell’s state.
    // dashboard-shell/src/index.tsx (simplified excerpt)
    import React, { Suspense, lazy, useState } from 'react';
    import ReactDOM from 'react-dom/client';
    
    // Type declarations for webpack globals
    declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
    declare const __webpack_share_scopes__: any;
    
    interface WidgetConfig {
      id: string; // Unique ID for dashboard layout
      title: string;
      remoteName: string; // e.g., 'chartWidgetApp'
      exposedModule: string; // e.g., './ChartWidget'
      remoteUrl: string; // e.g., 'http://localhost:4001/remoteEntry.js'
    }
    
    const availableWidgets: Omit<WidgetConfig, 'id'>[] = [
      { title: 'Chart Widget', remoteName: 'chartWidgetApp', exposedModule: './ChartWidget', remoteUrl: 'http://localhost:4001/remoteEntry.js' },
      { title: 'Table Widget', remoteName: 'tableWidgetApp', exposedModule: './TableWidget', remoteUrl: 'http://localhost:4002/remoteEntry.js' },
      { title: 'Text Widget', remoteName: 'textWidgetApp', exposedModule: './TextWidget', remoteUrl: 'http://localhost:4003/remoteEntry.js' },
    ];
    
    const loadRemoteWidget = async (config: Omit<WidgetConfig, 'id'>) => {
      // Basic check if remote already initialized (more robust version needed for production)
      if (!window[config.remoteName]) {
        await __webpack_init_sharing__('default');
        const script = document.createElement('script');
        script.src = config.remoteUrl;
        script.onerror = (e) => console.error(`Error loading remote ${config.remoteName}:`, e);
        await new Promise((resolve) => {
          script.onload = resolve;
          document.head.appendChild(script);
        });
        // @ts-ignore
        await window[config.remoteName].init(__webpack_share_scopes__.default);
      }
      // @ts-ignore
      const moduleFactory = await window[config.remoteName].get(config.exposedModule);
      return moduleFactory();
    };
    
    const App = () => {
      const [renderedWidgets, setRenderedWidgets] = useState<WidgetConfig[]>([]);
    
      const addWidget = async (widgetToAdd: Omit<WidgetConfig, 'id'>) => {
        const id = `${widgetToAdd.remoteName}-${Date.now()}`; // Unique ID for key prop
        setRenderedWidgets(prev => [...prev, { ...widgetToAdd, id }]);
      };
    
      return (
        <div style={{ fontFamily: 'Arial, sans-serif' }}>
          <nav style={{ padding: '15px', backgroundColor: '#444', color: 'white' }}>
            <h1>Dynamic Dashboard</h1>
          </nav>
    
          <div style={{ padding: '20px', borderBottom: '1px solid #ddd' }}>
            <h3>Available Widgets:</h3>
            <div style={{ display: 'flex', gap: '10px' }}>
              {availableWidgets.map((widget, index) => (
                <button
                  key={index}
                  onClick={() => addWidget(widget)}
                  style={{ padding: '10px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
                >
                  Add {widget.title}
                </button>
              ))}
            </div>
          </div>
    
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '20px', padding: '20px' }}>
            {renderedWidgets.map(widgetConfig => {
              const RemoteWidget = lazy(() => loadRemoteWidget(widgetConfig).then((mod: any) => ({ default: mod.default || mod })));
    
              return (
                <div key={widgetConfig.id} style={{ border: '1px solid #aaa', padding: '15px', borderRadius: '8px', boxShadow: '2px 2px 5px rgba(0,0,0,0.1)' }}>
                  <h4>{widgetConfig.title}</h4>
                  <Suspense fallback={<div>Loading {widgetConfig.title}...</div>}>
                    <RemoteWidget />
                  </Suspense>
                </div>
              );
            })}
          </div>
        </div>
      );
    };
    
    const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(<App />);
    
  2. Run All Applications: Start all widget applications and then the dashboard-shell. Observe how you can dynamically add widgets to the dashboard at runtime.

Further Exploration for Project 2:

  • Widget Configuration: Pass specific props to widgets to customize their data or appearance.
  • Drag-and-Drop Layout: Implement a drag-and-drop feature to rearrange widgets on the dashboard.
  • Persistent Layout: Save the user’s dashboard layout (which widgets are present and their order) to localStorage or a backend.
  • Inter-Widget Communication: Allow widgets to communicate with each other (e.g., a “Filter” widget affecting a “Chart” widget). A global event bus or a shared state management library (exposed as a federated module) could be used.
  • Error Handling: Implement robust error boundaries for each dynamically loaded widget.

6. Bonus Section: Further Learning and Resources

Congratulations on making it this far! Module Federation is a powerful tool, and continuous learning is key. Here are some excellent resources to deepen your understanding:

Official Documentation

Blogs and Articles

YouTube Channels

  • Look for channels specializing in modern JavaScript, React, Webpack, or Micro-Frontends. Some popular ones include:
    • Academind
    • Traversy Media
    • The Net Ninja
    • Fireship
    • And many independent content creators who deep dive into specific topics.

Community Forums/Groups

  • Stack Overflow: For specific technical questions and troubleshooting.
  • GitHub Repositories: The official Module Federation and Webpack repositories often have active discussions.
  • Discord Servers: Many large JavaScript and Webpack communities have active Discord servers where you can ask questions and engage with other developers.

Next Steps/Advanced Topics

After mastering the content in this document, consider exploring:

  • State Management in Micro-Frontends: Dive deeper into patterns for sharing and managing state across federated applications (e.g., custom event bus, shared Redux store as a federated module, context providers).
  • Routing in Micro-Frontends: How to achieve seamless navigation across different micro-frontends (e.g., using react-router-dom with careful configuration, specialized micro-frontend routers).
  • Server-Side Rendering (SSR) with Module Federation: This adds significant complexity but is crucial for SEO and initial page load performance in some applications.
  • Module Federation with other Build Tools: Explore integration with Vite (@module-federation/vite) or Rspack (built-in support).
  • Universal Module Federation (UMF): Explore solutions that allow sharing modules between different JavaScript runtimes (browser, Node.js).
  • Testing Micro-Frontends: Strategies for unit, integration, and end-to-end testing in a federated architecture.
  • Deployment and CI/CD for Micro-Frontends: Setting up independent pipelines for each micro-frontend, managing releases, and versioning.
  • Security Considerations: Best practices for securing federated applications.

Module Federation is a powerful and evolving technology. By diligently working through these concepts and projects, you’ll be well-equipped to tackle the challenges of building large-scale, distributed web applications. Happy coding!

For more advance stuff follow here -> Advanced Micro-Frontends with Module Federation: Mastering Scalability and Complexity (2025 Edition)