Micro-Frontend Performance Dashboard

Build a React micro-frontend dashboard with measured lazy loading.

Introduction

30 Second Summary

A dashboard can look simple while forcing every visitor to download features they may never open. That hidden wait makes the first visit feel slower before anyone has asked for the heavy part.

In this project, you will build a React shell that loads an independently deployed analytics micro-frontend only after someone requests it. The finished dashboard proves that delayed download with browser-visible timing.

What You'll Build

Your demo starts as a lightweight shell before the analytics panel arrives on demand with a visible load measurement.

By the end of this project, you'll have:

  • An independently runnable analytics panel exposed through Webpack Module Federation. A documented Storybook story lets you inspect the same component without starting the shell.
  • A lazy-loading proof in Google Chrome. The initial network activity contains no remoteEntry.js request. Selecting Open analytics fetches the remote before showing its load time plus request count.
  • Two independent HTTPS deployments on Cloudflare Pages. The shell consumes the analytics remote from its separate pages.dev URL.
  • Secret Mission: Add a React Error Boundary that keeps the shell useful when the analytics remote is offline.

Are there any prerequisites?

You should be comfortable with React. Step 1 checks Node.js compatibility before setting up the remaining tools.

Before We Start

Before you touch the toolchain, lock in the performance goal that guides this project. Your React shell stays interactive without downloading the independently deployed analytics remote until you select Open analytics.

Set Up the Pinned Toolchain

Your React shell and analytics remote eventually share dependencies across separate builds. Runtime or package mismatches can break that boundary before either application starts.

This custom Webpack setup needs one predictable toolchain. Exact package pins keep each build on the same dependency contract.

You will confirm that Node.js supports Storybook 10.6.2. You will also confirm access to Cloudflare Pages before deployment.

In this step, get ready to:
  • Confirm that Node.js meets Storybook's runtime requirement.
  • Prepare the project workspace with its pinned dependencies.
  • Confirm Cloudflare access and verify the local Webpack versions.
Check the Node.js release

Storybook 10.6.2 requires Node.js 20.19+ or 22.12+. Checking the runtime first prevents an incompatible release from causing confusing installation failures.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Node.js release by running this command:
node --version

How do I read the version?

The command prints the Node.js release available in this terminal. Compare that release with Storybook's supported requirement before continuing.

✔️ I see version 20.19+ or 22.12+

Your Node.js release satisfies Storybook's requirement. The runtime is ready for the pinned packages.

  • Continue to the workspace setup below.

ⓧ I see an older version

The installed release is too old for Storybook 10.6.2. Upgrade Node.js before installing the project dependencies.

  • Open the official Node.js download page in your browser.
  • Download the Windows installer shown on the page.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Complete the installation.
  • Close your existing PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Verify the upgraded release by running this command:
node --version

What should I see?

The fresh terminal should report a release that satisfies 20.19+ or 22.12+. That output confirms Windows is using the upgraded runtime.

ⓧ Command not found

PowerShell cannot find a Node.js installation. The official Windows installer adds Node.js and npm to your system.

  • Open the official Node.js download page in your browser.
  • Download the Windows installer shown on the page.
  • Run the downloaded installer.
  • Keep the default installation options.
  • Complete the installation.
  • Close your existing PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Verify the installation by running this command:
node --version

What should I see?

The command should now print a compatible Node.js release. That output confirms the installer added Node.js to the terminal path.

Still seeing the old release?

Close every open terminal after the installer finishes. A fresh terminal reloads the system path.

Still stuck? Help me check why Windows still reports the wrong Node.js release.

Create the workspace and install dependencies

The host and remote need one named workspace for their shared package manifest. You will create that folder on your Desktop before opening it in Visual Studio Code.

  • Press Win+E to open File Explorer.
  • Select Desktop in the left sidebar.
  • Select New in the toolbar.
  • Select Folder.
  • Enter micro-frontend-performance-dashboard as the folder name.
  • Press Enter to create the folder.

You should see the empty micro-frontend-performance-dashboard folder on your Desktop.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder.
  • Select Desktop in the folder picker.
  • Select the micro-frontend-performance-dashboard folder.
  • Click Select Folder.

You should see micro-frontend-performance-dashboard at the top of the Explorer sidebar. This confirms that VS Code is using the correct workspace.

  • Click the New File button beside the folder name in the Explorer sidebar.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Define the pinned toolchain by pasting this configuration into package.json:
{
  "name": "micro-frontend-performance-dashboard",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start:remote": "webpack serve --config remote/webpack.config.js --mode development --port 3001",
    "start:host": "webpack serve --config host/webpack.config.js --mode development --port 3000",
    "build:remote": "webpack --config remote/webpack.config.js --mode production",
    "build:host": "webpack --config host/webpack.config.js --mode production",
    "storybook": "storybook dev -c remote/.storybook",
    "build-storybook": "storybook build -c remote/.storybook"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@storybook/addon-webpack5-compiler-swc": "4.0.3",
    "@storybook/react-webpack5": "10.6.2",
    "@swc/core": "1.16.18",
    "storybook": "10.6.2",
    "swc-loader": "0.2.7",
    "webpack": "5.112.0",
    "webpack-cli": "7.2.3",
    "webpack-dev-server": "6.0.0"
  }
}

What does this configuration do?

  • The private setting protects the workspace from accidental publication as an npm package.
  • The type setting enables ESM configuration files.
  • The scripts provide separate development and production commands for the remote and host.
  • The dependencies pin React and React DOM to 19.3.1.
  • The development dependencies pin Webpack to 5.112.0.
  • The compiler dependencies use SWC for JSX transformation in Webpack and Storybook.
  • Save package.json by pressing Ctrl+S.
  • Confirm that the VS Code tab no longer shows an unsaved-change indicator.

Seeing JSON warnings?

Confirm that the first character is {. Confirm that the last character is }.

Still stuck? Help me compare my package.json with the pinned configuration.

The first dependency installation can take a few minutes while npm downloads the pinned packages. Ongoing terminal activity means the installation is progressing.

  • Select Terminal from the VS Code top menu.
  • Select New Terminal.
  • Install the pinned dependencies by running this command:
npm install

What does this command do?

npm reads the exact versions from package.json. It installs those packages inside node_modules/ for this workspace.

The local installation keeps the project independent from unrelated global packages.

  • Wait for the terminal to return to its prompt.
  • Confirm that node_modules appears in the Explorer sidebar.

That is the dependency foundation in place. Both applications can now use the same pinned package set.

Dependency installation failed?

  • Confirm that the terminal path ends with micro-frontend-performance-dashboard.
  • Confirm that package.json is saved inside that folder.
  • Check your internet connection because npm downloads packages during installation.

Still stuck? Help me diagnose the npm installation failure.

✔️ Awesome, I've got everything!

Great. Confirm that package.json is saved.

You should also see node_modules in the Explorer sidebar.

ⓧ I'd like to double check the full code

Compare your package.json with this complete pinned configuration.

{
  "name": "micro-frontend-performance-dashboard",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start:remote": "webpack serve --config remote/webpack.config.js --mode development --port 3001",
    "start:host": "webpack serve --config host/webpack.config.js --mode development --port 3000",
    "build:remote": "webpack --config remote/webpack.config.js --mode production",
    "build:host": "webpack --config host/webpack.config.js --mode production",
    "storybook": "storybook dev -c remote/.storybook",
    "build-storybook": "storybook build -c remote/.storybook"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@storybook/addon-webpack5-compiler-swc": "4.0.3",
    "@storybook/react-webpack5": "10.6.2",
    "@swc/core": "1.16.18",
    "storybook": "10.6.2",
    "swc-loader": "0.2.7",
    "webpack": "5.112.0",
    "webpack-cli": "7.2.3",
    "webpack-dev-server": "6.0.0"
  }
}
Confirm Cloudflare access and Webpack versions

Cloudflare Pages receives the two static build folders later in the project. Confirming account access now keeps deployment setup away from the build workflow.

Account setup can feel like a billing commitment. This project uses the Free plan without paid features or a custom domain.

  • Press the Windows key to open Windows search.
  • Type Google Chrome into the search field.
  • Press Enter to open Google Chrome.
  • Visit the Cloudflare dashboard.
  • Sign in to your existing Cloudflare account.
  • Create a Cloudflare account if you do not have one.
  • Choose the Free plan if Cloudflare asks you to select a plan.
  • Select Workers & Pages in the dashboard.

You should see the Workers & Pages area without an access warning. This confirms that the deployment target is available.

  • Switch back to Visual Studio Code.
  • Return to the terminal panel from earlier.

Before you run the final check, which Webpack and webpack-cli versions do you expect the local workspace to report?

  • Verify the installed Webpack toolchain by running this command:
npx webpack-cli --version

What does this verification prove?

npx resolves the webpack-cli package installed inside this workspace. The output identifies the local Webpack toolchain.

Matching the package pins proves that the upcoming host and remote builds use the intended versions.

You should see Webpack 5.112.0 reported. You should also see webpack-cli 7.2.3.

Your version-sensitive foundation is locked down. The local workspace and Cloudflare account are ready for the build.

Versions missing or different?

  • Confirm that the terminal path ends with micro-frontend-performance-dashboard.
  • Confirm that node_modules appears in the Explorer sidebar.
  • Run the dependency installation again if the local packages are missing.

Still stuck? Help me verify my local Webpack installation.

Your pinned toolchain is ready. Next, you will build the analytics remote as a standalone application.

Build and Document the Analytics Remote

The pinned toolchain is ready in Visual Studio Code. The analytics panel now needs an application that can render it without help from the future shell.

You will build a standalone React remote with its own Webpack build. You will also document the component in Storybook so it can be reviewed in isolation.

In this step, get ready to:
  • Create the standalone analytics application with an asynchronous bootstrap boundary.
  • Configure the remote build with SWC transformation plus automatic asset paths.
  • Render the analytics panel independently in Storybook.
Create the standalone remote

A remote needs its own HTML page before another application can consume it. An asynchronous bootstrap boundary gives Webpack time to initialize shared runtime dependencies.

  • Create the `remote/static` folder path in the VS Code file tree using its folder-creation control.
  • Create the `remote/src` folder path in the VS Code file tree using its folder-creation control.

You should see the `static` and `src` folders nested inside `remote`.

  • Create `remote/static/index.html` by adding this standalone document:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Analytics Remote</title>
  </head>
  <body>
    <div id="root"></div>
    <script defer src="./main.js"></script>
  </body>
</html>

What does this page provide?

  • The `root` element gives React a stable place to render the application.
  • The deferred `main.js` script starts after the browser parses the document.
  • Save `remote/static/index.html`.
  • Confirm that `index.html` appears inside `remote/static` in the VS Code file tree.

Can't find the HTML file?

Check that `static` sits inside `remote`. A file beside `remote` will not be copied into the remote build.

Still stuck? Help me check the remote folder structure.

The entry file has one responsibility. It crosses the asynchronous boundary before loading the renderer.

  • Create `remote/src/index.js` by adding this asynchronous import:
import("./bootstrap.jsx");

Why use an asynchronous import?

The dynamic import creates a separate bootstrap chunk. Webpack can initialize shared modules before React starts rendering.

  • Save `remote/src/index.js`.
  • Confirm that `index.js` appears inside `remote/src` in the VS Code file tree.

Is the bootstrap import unresolved?

Confirm that the import includes the leading `./`. Confirm that the filename uses the exact capitalization `bootstrap.jsx`.

Need another pair of eyes? Help me check this dynamic import.

The analytics component owns its presentation plus a readiness signal. Its optional `onReady` callback receives a timestamp from `performance.now()` after the component mounts.

  • Create `remote/src/AnalyticsPanel.jsx` by adding this first section:
import { useEffect } from "react";

const panelStyles = `
  .analytics-panel {
    color: #e2e8f0;
    background: linear-gradient(145deg, #0f172a, #1e293b);
    border: 1px solid #334155;
    border-radius: 18px;
    padding: 24px;
    box-shadow: 0 20px 55px rgba(15, 23, 42, 0.28);
  }

  .analytics-panel__eyebrow {
    margin: 0 0 8px;
    color: #38bdf8;
    font-size: 12px;
    font-weight: 800;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }

  .analytics-panel__title {
    margin: 0;
    font-size: 30px;
  }

  .analytics-panel__summary {
    margin: 8px 0 22px;
    color: #94a3b8;
  }

What does this section style?

  • `panelStyles` starts the component's embedded stylesheet.
  • The first selectors define the panel surface plus its heading hierarchy.
  • Keeping these styles inside the component avoids adding a separate stylesheet loader.
  • Continue `remote/src/AnalyticsPanel.jsx` by adding these metric layout styles directly below the previous section:
  .analytics-panel__grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 12px;
  }

  .analytics-panel__metric {
    padding: 16px;
    background: rgba(15, 23, 42, 0.7);
    border: 1px solid #334155;
    border-radius: 12px;
  }

  .analytics-panel__label {
    display: block;
    color: #94a3b8;
    font-size: 12px;
  }

What does this section style?

  • The grid places the three metrics into equal columns.
  • Each metric receives its own bordered card.
  • The label style separates each metric name from its value.
  • Continue `remote/src/AnalyticsPanel.jsx` by adding the value style plus the start of the component directly below the previous section:
  .analytics-panel__value {
    display: block;
    margin-top: 6px;
    font-size: 22px;
    font-weight: 800;
  }
`;

export default function AnalyticsPanel({ onReady }) {
  useEffect(() => {
    if (onReady) {
      onReady(performance.now());
    }
  }, [onReady]);

  return (
    <section className="analytics-panel" aria-label="Revenue analytics">
      <style>{panelStyles}</style>
      <p className="analytics-panel__eyebrow">Independent remote</p>
      <h2 className="analytics-panel__title">Revenue pulse</h2>
      <p className="analytics-panel__summary">
        This panel is compiled and released separately from the shell.
      </p>

How does readiness reporting work?

  • `useEffect()` runs after React mounts the panel.
  • The effect calls `onReady` only when a parent supplies the callback.
  • The callback receives a high-resolution timestamp in milliseconds.
  • Finish `remote/src/AnalyticsPanel.jsx` by adding the metric cards directly below the previous section:
      <div className="analytics-panel__grid">
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Monthly revenue</span>
          <span className="analytics-panel__value">$84.2K</span>
        </div>
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Conversion</span>
          <span className="analytics-panel__value">6.8%</span>
        </div>
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Active trials</span>
          <span className="analytics-panel__value">1,284</span>
        </div>
      </div>
    </section>
  );
}

What does the panel render?

  • The component renders a recognizable `Revenue pulse` heading.
  • The three cards show monthly revenue plus conversion plus active trials.
  • The component remains reusable because its content does not depend on the standalone page.
  • Save `remote/src/AnalyticsPanel.jsx`.
  • Confirm that `AnalyticsPanel.jsx` appears inside `remote/src` in the VS Code file tree.

Seeing JSX syntax warnings?

Check that the four sections appear in the same order. Confirm that the stylesheet closes with a backtick before the component begins.

If the structure still looks wrong, help me inspect the component syntax.

The bootstrap file owns the standalone render. It creates the browser root before placing `AnalyticsPanel` inside a simple page wrapper.

  • Create `remote/src/bootstrap.jsx` by adding this standalone renderer:
import { createRoot } from "react-dom/client";
import AnalyticsPanel from "./AnalyticsPanel.jsx";

const root = createRoot(document.getElementById("root"));

root.render(
  <main
    style={{
      maxWidth: 760,
      margin: "48px auto",
      padding: 24,
      fontFamily: "Inter, system-ui, sans-serif"
    }}
  >
    <p style={{ color: "#64748b" }}>Standalone remote build</p>
    <AnalyticsPanel />
  </main>
);

What does this renderer do?

  • `createRoot()` connects React to the `root` element from `index.html`.
  • The `main` wrapper keeps the standalone demonstration readable.
  • `AnalyticsPanel` renders without an `onReady` callback because that prop is optional.
  • Save `remote/src/bootstrap.jsx`.
  • Confirm that `bootstrap.jsx` appears beside `index.js` plus `AnalyticsPanel.jsx`.

Seeing an unresolved component import?

Check that `AnalyticsPanel.jsx` sits inside `remote/src`. Confirm that the import uses the same capitalization as the filename.

Need focused help? Help me compare the component path with its import.

Configure and run the remote build

Browsers do not execute JSX directly. SWC transforms the component while Webpack copies the static page into the build output.

The build also needs a unique runtime identity. `analytics-remote` protects this build from future runtime-name collisions with the shell.

  • Create `remote/webpack.config.js` by adding the path setup plus output configuration below:
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  context: __dirname,
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    chunkFilename: "[name].[contenthash].js",
    clean: true,
    copy: "static",
    publicPath: "auto",
    uniqueName: "analytics-remote"
  },
  resolve: {
    extensions: [".js", ".jsx"]
  },

What does this build setup control?

  • `context` makes relative paths resolve from the `remote` folder.
  • `output.copy` copies the contents of `remote/static` into `remote/dist`.
  • `publicPath: "auto"` lets Webpack determine asset locations from the runtime script URL.
  • `uniqueName: "analytics-remote"` gives the remote its own build identity.
  • Finish `remote/webpack.config.js` by adding this SWC rule directly below the previous section:
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: "swc-loader",
          options: {
            jsc: {
              parser: {
                syntax: "ecmascript",
                jsx: true
              },
              transform: {
                react: {
                  runtime: "automatic"
                }
              }
            }
          }
        }
      }
    ]
  }
};

What does the SWC rule do?

  • The rule sends JavaScript plus JSX files through `swc-loader`.
  • The parser accepts ECMAScript syntax plus JSX.
  • The automatic runtime compiles JSX without requiring every component to import React.
  • Save `remote/webpack.config.js`.
  • Confirm that `webpack.config.js` appears beside the `src` plus `static` folders.

Does the configuration fail to load?

Confirm that `webpack.config.js` sits directly inside `remote`. Check that the opening configuration section ends with a comma after `resolve`.

For focused help, help me inspect the remote configuration.

Before you start the remote, what content do you expect the standalone page to display?

  • Start the remote development server from the existing project terminal by running:
npm run start:remote

What does this command start?

The existing package script starts Webpack's development server with `remote/webpack.config.js`. The server uses port `3001`.

This process keeps the terminal occupied while it serves the remote. Leave it running for the rest of this step.

  • Open a new Google Chrome tab.
  • Enter `http://localhost:3001` in the address bar.

You should see `Standalone remote build` above a dark `Revenue pulse` panel. The panel should show `$84.2K` plus `6.8%` plus `1,284`.

That is the first independent proof point. Your analytics product now renders without the shell that eventually consumes it.

Does the remote page fail to load?

  • Confirm that the remote terminal is still running.
  • Check that `remote/static/index.html` loads `./main.js`.
  • Confirm that `remote/webpack.config.js` contains `copy: "static"`.

Still blocked? Help me debug the standalone remote.

Document the panel in Storybook

A reusable remote should be reviewable without starting its consumer. Storybook provides that isolated view through a named component story.

The documentation build needs the same JSX transformation as the remote. Its ESM configuration points at the story file plus the SWC compiler addon.

  • Create `remote/.storybook` in the VS Code file tree using its folder-creation control.
  • Create `remote/.storybook/main.js` by adding this configuration:
export default {
  stories: ["../src/**/*.stories.@(js|jsx)"],
  addons: ["@storybook/addon-webpack5-compiler-swc"],
  framework: {
    name: "@storybook/react-webpack5",
    options: {}
  },
  swc: (config) => ({
    ...config,
    jsc: {
      ...config.jsc,
      parser: {
        syntax: "ecmascript",
        jsx: true
      },
      transform: {
        ...config.jsc?.transform,
        react: {
          ...config.jsc?.transform?.react,
          runtime: "automatic"
        }
      }
    }
  })
};

What does this Storybook configuration do?

  • The `stories` pattern finds JavaScript story files inside `remote/src`.
  • The framework renders React components through Webpack.
  • The addon plus `swc` callback enables ECMAScript JSX with the automatic React runtime.
  • Save `remote/.storybook/main.js`.
  • Confirm that `main.js` appears inside `remote/.storybook`.

Is Storybook ignoring the configuration?

Confirm that the folder name begins with a dot. Check that `main.js` sits directly inside `remote/.storybook`.

Need help checking the path? Help me inspect the Storybook configuration location.

A story gives the component a stable name inside Storybook. The empty `Default` story renders the component without extra arguments because `onReady` is optional.

  • Create `remote/src/AnalyticsPanel.stories.jsx` by adding this component story:
import AnalyticsPanel from "./AnalyticsPanel.jsx";

export default {
  title: "Analytics/AnalyticsPanel",
  component: AnalyticsPanel
};

export const Default = {};

How is the story organized?

  • `Analytics/AnalyticsPanel` places the story inside an Analytics group.
  • The `component` field identifies the React component being documented.
  • The empty `Default` story uses the component's standard presentation.
  • Save `remote/src/AnalyticsPanel.stories.jsx`.
  • Confirm that `AnalyticsPanel.stories.jsx` appears beside `AnalyticsPanel.jsx`.

Is the story file missing?

Confirm that the filename ends with `.stories.jsx`. Check that the file sits inside `remote/src`.

If the paths look correct, help me compare the story with its configured pattern.

✔️ Awesome, I've got everything!

Your standalone remote plus its Storybook story are ready. Double check that every file is saved before starting Storybook.

ⓧ I'd like to double check the full code

{
  "name": "micro-frontend-performance-dashboard",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start:remote": "webpack serve --config remote/webpack.config.js --mode development --port 3001",
    "start:host": "webpack serve --config host/webpack.config.js --mode development --port 3000",
    "build:remote": "webpack --config remote/webpack.config.js --mode production",
    "build:host": "webpack --config host/webpack.config.js --mode production",
    "storybook": "storybook dev -c remote/.storybook",
    "build-storybook": "storybook build -c remote/.storybook"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@storybook/addon-webpack5-compiler-swc": "4.0.3",
    "@storybook/react-webpack5": "10.6.2",
    "@swc/core": "1.16.18",
    "storybook": "10.6.2",
    "swc-loader": "0.2.7",
    "webpack": "5.112.0",
    "webpack-cli": "7.2.3",
    "webpack-dev-server": "6.0.0"
  }
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Analytics Remote</title>
  </head>
  <body>
    <div id="root"></div>
    <script defer src="./main.js"></script>
  </body>
</html>
import("./bootstrap.jsx");
import { createRoot } from "react-dom/client";
import AnalyticsPanel from "./AnalyticsPanel.jsx";

const root = createRoot(document.getElementById("root"));

root.render(
  <main
    style={{
      maxWidth: 760,
      margin: "48px auto",
      padding: 24,
      fontFamily: "Inter, system-ui, sans-serif"
    }}
  >
    <p style={{ color: "#64748b" }}>Standalone remote build</p>
    <AnalyticsPanel />
  </main>
);
import { useEffect } from "react";

const panelStyles = `
  .analytics-panel {
    color: #e2e8f0;
    background: linear-gradient(145deg, #0f172a, #1e293b);
    border: 1px solid #334155;
    border-radius: 18px;
    padding: 24px;
    box-shadow: 0 20px 55px rgba(15, 23, 42, 0.28);
  }

  .analytics-panel__eyebrow {
    margin: 0 0 8px;
    color: #38bdf8;
    font-size: 12px;
    font-weight: 800;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }

  .analytics-panel__title {
    margin: 0;
    font-size: 30px;
  }

  .analytics-panel__summary {
    margin: 8px 0 22px;
    color: #94a3b8;
  }

  .analytics-panel__grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 12px;
  }

  .analytics-panel__metric {
    padding: 16px;
    background: rgba(15, 23, 42, 0.7);
    border: 1px solid #334155;
    border-radius: 12px;
  }

  .analytics-panel__label {
    display: block;
    color: #94a3b8;
    font-size: 12px;
  }

  .analytics-panel__value {
    display: block;
    margin-top: 6px;
    font-size: 22px;
    font-weight: 800;
  }
`;

export default function AnalyticsPanel({ onReady }) {
  useEffect(() => {
    if (onReady) {
      onReady(performance.now());
    }
  }, [onReady]);

  return (
    <section className="analytics-panel" aria-label="Revenue analytics">
      <style>{panelStyles}</style>
      <p className="analytics-panel__eyebrow">Independent remote</p>
      <h2 className="analytics-panel__title">Revenue pulse</h2>
      <p className="analytics-panel__summary">
        This panel is compiled and released separately from the shell.
      </p>
      <div className="analytics-panel__grid">
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Monthly revenue</span>
          <span className="analytics-panel__value">$84.2K</span>
        </div>
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Conversion</span>
          <span className="analytics-panel__value">6.8%</span>
        </div>
        <div className="analytics-panel__metric">
          <span className="analytics-panel__label">Active trials</span>
          <span className="analytics-panel__value">1,284</span>
        </div>
      </div>
    </section>
  );
}
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  context: __dirname,
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    chunkFilename: "[name].[contenthash].js",
    clean: true,
    copy: "static",
    publicPath: "auto",
    uniqueName: "analytics-remote"
  },
  resolve: {
    extensions: [".js", ".jsx"]
  },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: "swc-loader",
          options: {
            jsc: {
              parser: {
                syntax: "ecmascript",
                jsx: true
              },
              transform: {
                react: {
                  runtime: "automatic"
                }
              }
            }
          }
        }
      }
    ]
  }
};
export default {
  stories: ["../src/**/*.stories.@(js|jsx)"],
  addons: ["@storybook/addon-webpack5-compiler-swc"],
  framework: {
    name: "@storybook/react-webpack5",
    options: {}
  },
  swc: (config) => ({
    ...config,
    jsc: {
      ...config.jsc,
      parser: {
        syntax: "ecmascript",
        jsx: true
      },
      transform: {
        ...config.jsc?.transform,
        react: {
          ...config.jsc?.transform?.react,
          runtime: "automatic"
        }
      }
    }
  })
};
import AnalyticsPanel from "./AnalyticsPanel.jsx";

export default {
  title: "Analytics/AnalyticsPanel",
  component: AnalyticsPanel
};

export const Default = {};

The remote server is still using the first terminal. Storybook runs as a second long-lived process.

  • Create a second terminal in VS Code.

Before you start Storybook, do you expect `AnalyticsPanel` to need the shell before it can render?

  • Start Storybook from the project folder by running:
npm run storybook

What does this command start?

The existing script starts Storybook with the configuration in `remote/.storybook`. The terminal prints a local URL when the component documentation is ready.

  • Record the local URL printed by the Storybook terminal here: your local Storybook URL.
  • Open your local Storybook URL in a new Chrome tab.
  • Select **Analytics** in the Storybook sidebar.
  • Select **AnalyticsPanel**.
  • Select **Default**.

You should see the same `Revenue pulse` panel without the shell. The sidebar should place the story under `Analytics/AnalyticsPanel`.

You now have two independent proof points. The remote renders as an application at `http://localhost:3001` while the component renders as isolated documentation in Storybook.

Can't see the AnalyticsPanel story?

  • Confirm that `AnalyticsPanel.stories.jsx` sits inside `remote/src`.
  • Check that the story title is exactly `Analytics/AnalyticsPanel`.
  • Confirm that the Storybook terminal remains running.

If the story still does not appear, help me debug the Storybook discovery setup.

Your analytics remote now stands on its own with visual documentation. Next, you will expose the panel through Module Federation and connect it to the shell.

Federate the Remote and Observe Eager Loading

Your analytics remote now runs independently. Its Storybook story also proves that the panel works outside a host application.

This step connects the remote to a shell through Webpack Module Federation. The shell uses a static import first so you can inspect when the browser fetches the remote.

In this step, get ready to:
  • Expose the analytics panel through a federated runtime contract.
  • Create a separate host application that consumes the remote.
  • Prove that the static import downloads the remote before the panel opens.
Expose the analytics panel through federation

Module Federation gives the remote a public module contract. The shell can request that contract without copying the component into its own source files.

  • In the existing remote/webpack.config.js file, find this import:
import webpack from "webpack";

Why Find This Import?

Webpack exposes ModuleFederationPlugin through its container API. This existing import gives the configuration access to that API.

  • Add the plugin extraction directly below the import so this section looks like:
import webpack from "webpack";

const { ModuleFederationPlugin } = webpack.container;

What Does This Extraction Do?

The destructuring line retrieves ModuleFederationPlugin from Webpack. The remote configuration can now create a federation plugin instance.

  • Add the following plugins section after the existing module section:
  plugins: [
    new ModuleFederationPlugin({
      name: "analytics",
      filename: "remoteEntry.js",
      exposes: {
        "./AnalyticsPanel": "./src/AnalyticsPanel.jsx"
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: "19.3.1"
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "19.3.1"
        }
      }
    })
  ]

What Does the Federation Contract Define?

  • The analytics name identifies this remote to consuming builds.
  • The remoteEntry.js file publishes the remote runtime contract.
  • The ./AnalyticsPanel key exposes the component from ./src/AnalyticsPanel.jsx.
  • The singleton settings keep one shared copy of React at version 19.3.1 in the page.
  • Save remote/webpack.config.js.
  • Switch back to the remote terminal from the previous step.
  • Stop the running process by pressing Ctrl+C.
  • Restart the federated remote by running:
npm run start:remote

What Does This Command Start?

The command rebuilds the remote with its federation contract. It keeps the terminal occupied while the development server runs on port 3001.

  • Open http://localhost:3001/remoteEntry.js in the browser to confirm that the federation entry exists.

You should see generated JavaScript for the analytics container. Your remote now publishes ./AnalyticsPanel for another build to consume.

Remote Entry Missing?

  • Check that the plugins section sits inside the exported configuration object.
  • Check that the filename is exactly remoteEntry.js.
  • Check the remote terminal for a configuration syntax error.

Still stuck? Help me debug my federated remote configuration.

Create the host shell

The host is a second build with its own HTML document. Its asynchronous bootstrap keeps shared dependency initialization behind a separate module boundary.

You will create the host inside the existing micro-frontend-performance-dashboard workspace in VS Code.

  • Create a host folder inside micro-frontend-performance-dashboard from the Explorer sidebar.

You should see host beside the existing remote folder.

  • Create a static folder inside host.
  • Create a src folder inside host.

The Explorer sidebar should now list host/static plus host/src.

  • Create index.html inside host/static with this document:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Micro-Frontend Performance Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script defer src="./main.js"></script>
  </body>
</html>

What Does the Host Document Provide?

The root element gives the host application a mounting point. The deferred main.js script loads the host bundle after the document has been parsed.

  • Save host/static/index.html.

You should see index.html listed under host/static.

  • Create index.js inside host/src with the asynchronous entry:
import("./bootstrap.jsx");

Why Use an Asynchronous Entry?

The dynamic import creates the asynchronous boundary recommended for shared Module Federation initialization. Webpack can prepare the shared dependency scope before the host renders.

  • Create bootstrap.jsx beside index.js with the host renderer:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

const root = createRoot(document.getElementById("root"));
root.render(<App />);

What Does the Bootstrap Render?

The host creates a React root from the existing root element. It renders the shell through the App component.

  • Save host/src/index.js.
  • Save host/src/bootstrap.jsx.

You should now see both entry files under host/src.

Host Entry Files in the Wrong Folder?

  • Move index.html into host/static.
  • Move index.js into host/src.
  • Move bootstrap.jsx into host/src.

Need help? Help me check my host folder structure.

The temporary shell imports the federated component at module scope. Its isOpen state controls when the panel becomes visible.

  • Create App.jsx inside host/src with this temporary shell:
import { useState } from "react";
import AnalyticsPanel from "analytics/AnalyticsPanel";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <main className="shell">
      <div className="shell__inner">
        <p className="shell__eyebrow">Host application</p>
        <h1>Micro-frontend performance dashboard</h1>
        <p className="shell__intro">
          The shell is already interactive. The independently built analytics
          panel stays off the network until you request it.
        </p>
        <div className="shell__controls">
          <button className="shell__button" type="button" onClick={() => setIsOpen(true)} disabled={isOpen}>
            Open analytics
          </button>
          <span className="shell__status">{isOpen ? "Remote requested" : "Remote not requested"}</span>
        </div>
        {isOpen ? <AnalyticsPanel /> : null}
      </div>
    </main>
  );
}

How Does the Temporary Shell Work?

  • The static import connects AnalyticsPanel to the remote module exposed as analytics/AnalyticsPanel.
  • The isOpen state begins as false.
  • The button changes isOpen to true.
  • The conditional expression renders AnalyticsPanel after the selection.
  • Save host/src/App.jsx.

The Explorer sidebar should now show App.jsx with the two host entry files.

Seeing a JSX Syntax Warning?

  • Check that the file extension is .jsx.
  • Check that every opening JSX element has a matching closing element.
  • Check the quotes around analytics/AnalyticsPanel.

Still stuck? Help me find the JSX problem in my temporary host App.

The host needs its own build identity. It also needs the remote location that resolves the analytics import at runtime.

  • Create webpack.config.js inside host with this base configuration:
import path from "node:path";
import { fileURLToPath } from "node:url";
import webpack from "webpack";

const { ModuleFederationPlugin } = webpack.container;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default (env = {}) => {
  const remoteBaseUrl = String(
    env.remoteUrl || "http://localhost:3001"
  ).replace(/\/$/, "");

  return {
    context: __dirname,
    entry: "./src/index.js",
    output: {
      path: path.resolve(__dirname, "dist"),
      filename: "main.js",
      chunkFilename: "[name].[contenthash].js",
      clean: true,
      copy: "static",
      publicPath: "auto",
      uniqueName: "performance-shell"
    },
  };
};

What Does the Host Output Configure?

  • The remoteBaseUrl value defaults to the local remote origin.
  • The copy setting places the static HTML in host/dist.
  • The publicPath setting lets Webpack derive asset locations at runtime.
  • The performance-shell name keeps the host runtime identity separate from the remote.
  • Save host/webpack.config.js.

You should see webpack.config.js directly inside host.

  • Insert the JSX resolution and SWC transformation sections after output inside the returned configuration object:
    resolve: {
      extensions: [".js", ".jsx"]
    },
    module: {
      rules: [
        {
          test: /\.[jt]sx?$/,
          exclude: /node_modules/,
          use: {
            loader: "swc-loader",
            options: {
              jsc: {
                parser: {
                  syntax: "ecmascript",
                  jsx: true
                },
                transform: {
                  react: {
                    runtime: "automatic"
                  }
                }
              }
            }
          }
        }
      ]
    },

How Does the Host Compile JSX?

The extension list lets imports omit .js or .jsx endings. The SWC rule transforms JSX with the automatic React runtime.

  • Insert the federation plugin after the module section:
    plugins: [
      new ModuleFederationPlugin({
        name: "shell",
        remotes: {
          analytics: `analytics@${remoteBaseUrl}/remoteEntry.js`
        },
        shared: {
          react: {
            singleton: true,
            requiredVersion: "19.3.1"
          },
          "react-dom": {
            singleton: true,
            requiredVersion: "19.3.1"
          }
        }
      })
    ]

How Does the Host Find the Remote?

  • The shell name identifies the host container.
  • The analytics mapping points the import scope to the remote container.
  • The template literal resolves to analytics@http://localhost:3001/remoteEntry.js during local development.
  • The shared settings require singleton React dependencies at version 19.3.1.
  • Save host/webpack.config.js.

✔️ Awesome, I've got everything!

  • Confirm that every host file is saved.
  • Confirm that remote/webpack.config.js includes the analytics federation plugin.

ⓧ I'd like to double check the full code

Compare each changed or newly created file with the references below.

import path from "node:path";
import { fileURLToPath } from "node:url";
import webpack from "webpack";

const { ModuleFederationPlugin } = webpack.container;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  context: __dirname,
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    chunkFilename: "[name].[contenthash].js",
    clean: true,
    copy: "static",
    publicPath: "auto",
    uniqueName: "analytics-remote"
  },
  resolve: {
    extensions: [".js", ".jsx"]
  },
  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: {
          loader: "swc-loader",
          options: {
            jsc: {
              parser: {
                syntax: "ecmascript",
                jsx: true
              },
              transform: {
                react: {
                  runtime: "automatic"
                }
              }
            }
          }
        }
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "analytics",
      filename: "remoteEntry.js",
      exposes: {
        "./AnalyticsPanel": "./src/AnalyticsPanel.jsx"
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: "19.3.1"
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "19.3.1"
        }
      }
    })
  ]
};
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Micro-Frontend Performance Dashboard</title>
  </head>
  <body>
    <div id="root"></div>
    <script defer src="./main.js"></script>
  </body>
</html>
import("./bootstrap.jsx");
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

const root = createRoot(document.getElementById("root"));
root.render(<App />);
import { useState } from "react";
import AnalyticsPanel from "analytics/AnalyticsPanel";

export default function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <main className="shell">
      <div className="shell__inner">
        <p className="shell__eyebrow">Host application</p>
        <h1>Micro-frontend performance dashboard</h1>
        <p className="shell__intro">
          The shell is already interactive. The independently built analytics
          panel stays off the network until you request it.
        </p>
        <div className="shell__controls">
          <button className="shell__button" type="button" onClick={() => setIsOpen(true)} disabled={isOpen}>
            Open analytics
          </button>
          <span className="shell__status">{isOpen ? "Remote requested" : "Remote not requested"}</span>
        </div>
        {isOpen ? <AnalyticsPanel /> : null}
      </div>
    </main>
  );
}
import path from "node:path";
import { fileURLToPath } from "node:url";
import webpack from "webpack";

const { ModuleFederationPlugin } = webpack.container;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default (env = {}) => {
  const remoteBaseUrl = String(
    env.remoteUrl || "http://localhost:3001"
  ).replace(/\/$/, "");

  return {
    context: __dirname,
    entry: "./src/index.js",
    output: {
      path: path.resolve(__dirname, "dist"),
      filename: "main.js",
      chunkFilename: "[name].[contenthash].js",
      clean: true,
      copy: "static",
      publicPath: "auto",
      uniqueName: "performance-shell"
    },
    resolve: {
      extensions: [".js", ".jsx"]
    },
    module: {
      rules: [
        {
          test: /\.[jt]sx?$/,
          exclude: /node_modules/,
          use: {
            loader: "swc-loader",
            options: {
              jsc: {
                parser: {
                  syntax: "ecmascript",
                  jsx: true
                },
                transform: {
                  react: {
                    runtime: "automatic"
                  }
                }
              }
            }
          }
        }
      ]
    },
    plugins: [
      new ModuleFederationPlugin({
        name: "shell",
        remotes: {
          analytics: `analytics@${remoteBaseUrl}/remoteEntry.js`
        },
        shared: {
          react: {
            singleton: true,
            requiredVersion: "19.3.1"
          },
          "react-dom": {
            singleton: true,
            requiredVersion: "19.3.1"
          }
        }
      })
    ]
  };
};

Host Configuration Not Compiling?

  • Check that resolve appears after output inside the returned object.
  • Check that plugins appears after module.
  • Check the commas between the output plus resolve plus module sections.

Need a second pair of eyes? Help me debug my host Webpack configuration.

Observe the eager remote request

The shell can now resolve the exposed analytics component. The network log reveals when that remote contract enters the page.

  • Keep the remote development terminal running.
  • Add another terminal session in the existing VS Code terminal panel.
  • Start the host development server by running:
npm run start:host

What Does the Host Command Start?

The command builds the shell with host/webpack.config.js. It keeps the terminal occupied while the development server runs on port 3000.

  • Open http://localhost:3000 in Google Chrome.

You should see the shell heading plus the Open analytics button. The analytics panel should still be hidden.

  • Open Chrome DevTools for the shell page.
  • Select the Network panel.
  • Clear the current network log.

Before you reload, do you think hiding the panel with isOpen also delays its network request?

  • Reload http://localhost:3000 with the Network panel open.
  • Leave Open analytics untouched.
  • Filter the request list for remoteEntry.js.

You should see a remoteEntry.js request before selecting the button. The static import makes Webpack resolve the remote during startup.

That shortfall is intentional. Conditional rendering controls when React displays the panel while the module import controls when its code enters the loading graph.

  • Select Open analytics.

You should now see the Revenue pulse panel with its three metrics. The earlier Network entry proves that the download happened before this interaction.

Remote Request Missing or Panel Blank?

  • Confirm that the remote terminal still serves http://localhost:3001.
  • Confirm that the host terminal serves http://localhost:3000.
  • Check that the host remote mapping contains analytics@${remoteBaseUrl}/remoteEntry.js.

Still stuck? Help me debug the missing federated request.

Your shell now consumes an independently built remote through Module Federation. Next, you will move that request behind user intent and measure the improvement.

Lazy-Load and Measure the Remote

The shell now consumes the analytics remote through Webpack Module Federation. Chrome DevTools exposed the cost of the static import because remoteEntry.js arrives before anyone asks for analytics.

This step moves the remote behind a React lazy boundary. You will also turn its loading lifecycle into browser-visible performance evidence.

In this step, get ready to:
  • Load the federated analytics panel only after someone requests it.
  • Capture the request timestamp plus the remote mount timestamp.
  • Display the measured load time plus the remote-entry request count.
Move the remote behind user intent

The lazy() function waits to call its loader until React tries to render the component. Since the analytics panel only renders after the button changes isOpen, the remote request now follows that click.

  • Switch back to host/src/App.jsx in the VS Code editor.
  • Replace the React import plus the direct remote import with this lazy-loading setup:
import { lazy, Suspense, useState } from "react";

const AnalyticsPanel = lazy(() => import("analytics/AnalyticsPanel"));

What Changes in the Import?

  • The lazy() declaration stores a loader for the federated component.
  • The dynamic import() runs when AnalyticsPanel first enters the rendered interface.
  • The Suspense import prepares the shell to show useful UI during that request.
  • Save host/src/App.jsx.

Before you reload, do you expect remoteEntry.js to appear before the button is selected?

  • Return to the Network panel in Chrome DevTools from earlier.
  • Clear the current request log with the clear icon.
  • Reload http://localhost:3000.

You should see the shell without a remoteEntry.js request. The static startup download has disappeared.

  • Select Open analytics.

You should see remoteEntry.js enter the Network log. The Revenue pulse panel loads after the request finishes.

A Suspense boundary keeps the shell informative while the lazy component is in flight. The remote already reports its mount timestamp through onReady, so the shell needs state to receive that value.

  • Find the existing isOpen state inside App.
  • Add this state declaration directly below it:
  const [readyAt, setReadyAt] = useState(null);

What Does This State Hold?

The readyAt value stores the timestamp reported after the analytics panel mounts. Its setter travels into the remote as the onReady callback.

  • Find the conditional block that renders AnalyticsPanel.
  • Replace that conditional block with this Suspense boundary:
        {isOpen ? (
          <Suspense
            fallback={
              <div className="shell__loading">Loading remote analytics...</div>
            }
          >
            <AnalyticsPanel onReady={setReadyAt} />
          </Suspense>
        ) : null}

How Does the Boundary Help?

  • The conditional keeps the lazy component out of the rendered tree until isOpen becomes true.
  • The fallback provides the Loading remote analytics message while the remote code is downloading.
  • The onReady prop records the timestamp reported by the mounted remote.
  • Save host/src/App.jsx.
  • Clear the Network request log.
  • Reload the shell.
  • Select Open analytics.

You should briefly see Loading remote analytics while the federated code resolves. The Revenue pulse panel then replaces the fallback.

Remote Still Loads on Refresh?

  • Check that the direct import from analytics/AnalyticsPanel has been removed.
  • Confirm that AnalyticsPanel is declared with lazy() above App.
  • Clear the Network log before reloading so requests from the previous navigation do not remain visible.

Still stuck? Help me find why my federated remote still downloads during shell startup.

Capture the loading timestamps

A load duration needs two points from the same browser clock. The shell captures the first with performance.now() when the button handler runs.

The remote supplies the second timestamp after it mounts. Subtracting those values measures the full interval from user intent to ready UI.

  • Find the readyAt state declaration inside App.
  • Replace that line with these two timestamp states:
  const [startedAt, setStartedAt] = useState(null);
  const [readyAt, setReadyAt] = useState(null);

Why Keep Two Timestamps?

  • The startedAt value records when the shell requests analytics.
  • The readyAt value records when the remote reports that its panel has mounted.
  • Both values come from the same high-resolution browser clock.
  • Find the existing openAnalytics function.
  • Replace the function with this timestamp-aware handler:
  function openAnalytics() {
    setStartedAt(performance.now());
    setReadyAt(null);
    setIsOpen(true);
  }

What Happens on Selection?

  • The handler captures the request time before the remote enters the rendered tree.
  • The setReadyAt(null) call clears any previous completion value.
  • The setIsOpen(true) call triggers the lazy render.
  • Save host/src/App.jsx.
  • Reload the shell.
  • Select Open analytics.

You should see the shell status change to Remote requested. The analytics panel should render through the same click that captures startedAt.

Fallback or Panel Missing?

  • Confirm that the remote terminal from earlier is still running.
  • Check that Suspense appears in the React import.
  • Confirm that AnalyticsPanel receives onReady={setReadyAt}.

Need another pair of eyes? Help me debug my missing Suspense fallback or analytics panel.

Calculate and display both measurements

The shell can now calculate elapsed milliseconds from the two timestamps. It can also inspect the browser's resource entries to count requests whose names contain remoteEntry.js.

  • Add these derived measurements below the three state declarations:
  const loadMs =
    startedAt !== null && readyAt !== null
      ? Math.round((readyAt - startedAt) * 10) / 10
      : null;

  const remoteEntryRequests =
    readyAt === null
      ? 0
      : performance
          .getEntriesByType("resource")
          .filter((entry) => entry.name.includes("remoteEntry.js")).length;

How Are the Measurements Calculated?

  • The loadMs expression waits until both timestamps exist.
  • The subtraction calculates elapsed milliseconds before rounding to one decimal place.
  • The performance.getEntriesByType("resource") call reads resources loaded during the current navigation.
  • The filter counts entries whose URL includes remoteEntry.js.
  • Find the conditional analytics block inside shell__inner.
  • Add this metrics section directly below that block:
        <section className="shell__metrics" aria-label="Remote performance">
          <div className="shell__metric">
            <span>Measured remote load</span>
            <strong>{loadMs === null ? "Not loaded" : `${loadMs} ms`}</strong>
          </div>
          <div className="shell__metric">
            <span>remoteEntry.js requests this navigation</span>
            <strong>{remoteEntryRequests}</strong>
          </div>
        </section>

What Does the Shell Show?

  • Measured remote load starts as Not loaded before the analytics panel mounts.
  • The value changes to elapsed milliseconds after onReady supplies the completion timestamp.
  • The second card shows how many matching remote-entry resources the browser recorded.
  • Save host/src/App.jsx.

Before you reload, which values do you expect to change only after you request analytics?

  • Return to the Network panel in Chrome DevTools.
  • Clear the current request log with the clear icon.
  • Reload http://localhost:3000.
  • Confirm that remoteEntry.js is absent from the initial request list.
  • Select Open analytics.

You should see the Revenue pulse panel plus a millisecond value under Measured remote load. The request-count card should show at least one remoteEntry.js request for this navigation.

That is the performance improvement proved in the browser. The shell becomes interactive before it spends network time on analytics.

✔️ Awesome, I've got everything!

Your shell now defers the federated remote until the button is selected. It also reports the load duration plus the remote-entry request count.

ⓧ I'd like to double check the full code

import { lazy, Suspense, useState } from "react";

const AnalyticsPanel = lazy(() => import("analytics/AnalyticsPanel"));

const shellStyles = `
  * { box-sizing: border-box; }
  body { margin: 0; background: #f8fafc; color: #0f172a; }
  button { font: inherit; }
  .shell {
    min-height: 100vh;
    padding: 48px 24px;
    font-family: Inter, system-ui, sans-serif;
  }
  .shell__inner { max-width: 900px; margin: 0 auto; }
  .shell__eyebrow {
    margin: 0 0 8px;
    color: #2563eb;
    font-size: 12px;
    font-weight: 800;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }
  .shell h1 { margin: 0; font-size: clamp(32px, 6vw, 54px); }
  .shell__intro { max-width: 680px; color: #475569; line-height: 1.6; }
  .shell__controls { display: flex; gap: 12px; align-items: center; margin: 28px 0; }
  .shell__button {
    border: 0;
    border-radius: 10px;
    padding: 12px 18px;
    color: white;
    background: #2563eb;
    cursor: pointer;
  }
  .shell__button:disabled { background: #94a3b8; cursor: default; }
  .shell__status { color: #64748b; }
  .shell__loading {
    padding: 28px;
    border: 1px dashed #94a3b8;
    border-radius: 14px;
    color: #475569;
    background: white;
  }
  .shell__metrics {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
    margin-top: 18px;
  }
  .shell__metric {
    padding: 14px 16px;
    border: 1px solid #cbd5e1;
    border-radius: 12px;
    background: white;
  }
  .shell__metric span { display: block; color: #64748b; font-size: 12px; }
  .shell__metric strong { display: block; margin-top: 4px; font-size: 20px; }
`;

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [startedAt, setStartedAt] = useState(null);
  const [readyAt, setReadyAt] = useState(null);

  const loadMs =
    startedAt !== null && readyAt !== null
      ? Math.round((readyAt - startedAt) * 10) / 10
      : null;

  const remoteEntryRequests =
    readyAt === null
      ? 0
      : performance
          .getEntriesByType("resource")
          .filter((entry) => entry.name.includes("remoteEntry.js")).length;

  function openAnalytics() {
    setStartedAt(performance.now());
    setReadyAt(null);
    setIsOpen(true);
  }

  return (
    <main className="shell">
      <style>{shellStyles}</style>
      <div className="shell__inner">
        <p className="shell__eyebrow">Host application</p>
        <h1>Micro-frontend performance dashboard</h1>
        <p className="shell__intro">
          The shell is already interactive. The independently built analytics
          panel stays off the network until you request it.
        </p>

        <div className="shell__controls">
          <button
            className="shell__button"
            type="button"
            onClick={openAnalytics}
            disabled={isOpen}
          >
            Open analytics
          </button>
          <span className="shell__status">
            {isOpen ? "Remote requested" : "Remote not requested"}
          </span>
        </div>

        {isOpen ? (
          <Suspense
            fallback={
              <div className="shell__loading">Loading remote analytics...</div>
            }
          >
            <AnalyticsPanel onReady={setReadyAt} />
          </Suspense>
        ) : null}

        <section className="shell__metrics" aria-label="Remote performance">
          <div className="shell__metric">
            <span>Measured remote load</span>
            <strong>{loadMs === null ? "Not loaded" : `${loadMs} ms`}</strong>
          </div>
          <div className="shell__metric">
            <span>remoteEntry.js requests this navigation</span>
            <strong>{remoteEntryRequests}</strong>
          </div>
        </section>
      </div>
    </main>
  );
}

How to Compare the File

The reference keeps the existing shell styles plus interface intact. The functional changes are the lazy declaration, timestamp state, Suspense boundary, derived measurements, and metric cards.

Metrics Stay at Not Loaded?

  • Check that openAnalytics calls setStartedAt(performance.now()) before opening the panel.
  • Confirm that the remote component receives onReady={setReadyAt}.
  • Check that loadMs appears inside the Measured remote load card.

Still seeing empty values? Help me trace why my remote timing or request count never updates.

Your shell now pays the analytics download cost only after user intent. Next, you will deploy the shell plus remote to separate HTTPS origins.

Deploy the Two Builds Independently

Your local shell now waits for user intent before fetching the analytics remote. Separate localhost ports prove the loading pattern. They do not prove that each application can ship from its own origin.

This step publishes each build as its own Cloudflare Pages deployment. The shell points to the remote's public HTTPS origin while preserving your lazy-loading measurements.

In this step, get ready to:
  • Publish the analytics remote at its own HTTPS URL.
  • Build the shell against the deployed remote origin.
  • Publish the shell at a second HTTPS URL.
Publish the analytics remote

A production build turns the remote source into static files that a browser can download. Direct Upload publishes those files without coupling the deployment to a Git repository.

  • Return to Visual Studio Code from earlier.
  • Create another terminal in the terminal panel by selecting the plus icon.
  • Build the production remote by running this command:
npm run build:remote

What Did This Build Create?

  • Webpack compiles the remote into browser-ready files inside remote/dist.
  • The generated remoteEntry.js file advertises the remote's exposed ./AnalyticsPanel module.
  • The generated index.html and main.js files keep the remote usable as a standalone application.
  • In the Visual Studio Code Explorer sidebar, expand the remote folder.
  • Expand the dist folder.

You should see index.html, main.js, remoteEntry.js, plus generated chunk files. These files form the deployable remote.

Remote Build Missing?

  • Confirm that the terminal prompt ends with micro-frontend-performance-dashboard.
  • Check the terminal output for a file path that identifies the failed source file.
  • Compare that file path with the matching file in the Visual Studio Code Explorer sidebar.

Still stuck? Help me fix the missing remote build output.

The remote/dist folder is a complete deployment artifact. Cloudflare can serve it directly from a public origin.

  • Switch back to the Cloudflare dashboard from earlier in Chrome.
  • Select Workers & Pages.
  • Select Create application.
  • Select Get started.
  • Select Drag and drop your files.
  • Enter your unique lowercase remote project name in the Project name field.
  • In File Explorer, open micro-frontend-performance-dashboard/remote/dist.
  • Drag the contents of remote/dist into the upload area.
  • Select Deploy site.

Cloudflare creates a separate Pages project. The deployment screen shows its public pages.dev URL when the upload finishes.

  • Record the returned remote URL here: https://your-remote-project.pages.dev.
  • Open https://your-remote-project.pages.dev in a new Chrome tab.

You should see the standalone remote with Revenue pulse and its three metrics. You have the first deployment live. The analytics panel now runs from its own HTTPS origin.

Remote Page Not Loading?

  • Confirm that you uploaded the contents of remote/dist.
  • Check that index.html sits directly inside the uploaded folder.
  • Confirm that the remote deployment finished before opening its public URL.

Need another pair of eyes? Help me diagnose the deployed remote page.

Prepare the production shell

The shell needs the deployed remote origin when Webpack creates its production bundle. The existing configuration accepts that origin through env.remoteUrl.

  • Return to the free Visual Studio Code terminal you used for production builds.
  • Replace https://your-remote-project.pages.dev in the command below with https://your-remote-project.pages.dev.
  • Build the production shell against the deployed remote by running the edited command:
npm run build:host -- --env remoteUrl=https://your-remote-project.pages.dev

How Does the Shell Find the Remote?

  • The command passes the deployed origin into env.remoteUrl.
  • The configuration stores the origin in remoteBaseUrl.
  • Module Federation resolves the analytics scope through analytics@${remoteBaseUrl}/remoteEntry.js.
  • The shell still waits to request that remote entry until React renders the lazy analytics component.
  • In the Visual Studio Code Explorer sidebar, expand the host folder.
  • Expand the dist folder.

You should see index.html, main.js, plus generated chunk files. This version of host/dist points to the deployed remote origin.

Host Build Failing?

  • Confirm that the command contains the recorded remote URL after remoteUrl=.
  • Check that the remote URL starts with https://.
  • Confirm that the terminal prompt ends with micro-frontend-performance-dashboard.

Still blocked? Help me diagnose the production shell build.

Prove independent delivery

The production shell is now a second static artifact. Publishing it as a separate Pages project proves that the shell can load code from another deployment.

  • Return to Workers & Pages in the Cloudflare dashboard.
  • Select Create application.
  • Select Get started.
  • Select Drag and drop your files.
  • Enter your unique lowercase shell project name in the Project name field.
  • In File Explorer, open micro-frontend-performance-dashboard/host/dist.
  • Drag the contents of host/dist into the upload area.
  • Select Deploy site.

Cloudflare creates a second Pages project with its own public URL. The remote project remains separate from this shell deployment.

  • Record the returned shell URL here: your deployed shell pages.dev URL.
  • Open an Incognito window in Chrome.
  • Paste your deployed shell pages.dev URL into the address bar.
  • Press Enter.
  • Open Chrome DevTools in the Incognito window.
  • Select the Network panel.
  • Clear the Network log.

Before you reload, do you expect the deployed shell to request remoteEntry.js immediately?

  • Reload the deployed shell page.

You should see the shell with no analytics panel. Its status reads Remote not requested.

The measured load reads Not loaded. The Network log contains no remoteEntry.js request.

Before you select the button, when do you expect the remote request to enter the Network log?

  • Select Open analytics.

You should see the deployed Revenue pulse panel. The measured remote load changes to a number of milliseconds.

The Network log now contains remoteEntry.js. The request-count metric shows 1 for this navigation.

Deployed Panel Not Appearing?

  • Open the recorded remote URL directly to confirm that Revenue pulse still loads.
  • Confirm that you built host/dist after replacing the example origin with the deployed remote URL.
  • Rebuild the shell if the remote project received a different public URL.

Need help tracing the request? Help me debug the deployed shell and remote connection.

Your browser has now proved the architecture across two public origins. The shell remains interactive before the independently deployed analytics code enters the page.

Why Are These Deployments Independent?

The remote has its own Pages project. The shell only depends on its public origin plus the exposed ./AnalyticsPanel contract.

You can release a new remote/dist build without rebuilding the shell when that origin stays stable. The exposed contract must also remain compatible.

A changed remote origin requires a new shell build. An incompatible exposed contract also requires coordinated changes.

You now have two independently deployed applications. Your browser-visible metrics prove that the remote stays off the network until the shell requests it.

Secret mission

Survive a Remote Outage

An independently deployed analytics remote can become unavailable while the shell remains healthy. Add a React Error Boundary that contains the failure inside the analytics region and keeps the rest of the dashboard usable.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources available, pause the local processes, or delete everything. This project uses the Cloudflare Pages Free plan, so no paid features continue charging after you finish.

Resources you used:

  • The local micro-frontend-performance-dashboard folder with its installed npm dependencies.
  • One Cloudflare Pages project serving the analytics remote at a public pages.dev URL.
  • One Cloudflare Pages project serving the final shell at a public pages.dev URL.

Keep everything running

No cloud action is needed. Choose this option while you are still developing or demonstrating the dashboard.

  • Keep the Visual Studio Code terminal running the local Webpack shell while you are testing changes.
  • Keep the Storybook terminal running while you are reviewing the analytics component.
  • Press Ctrl+C in each terminal when you finish coding for the day.

Both public deployments remain available at their existing URLs. Your local workspace remains ready for future changes.

Pause - I'll come back to this later

Shut down the running local processes to free your computer's resources. The remote terminal is already stopped from the Secret Mission.

  • Return to the Visual Studio Code terminal running the local shell.
  • Press Ctrl+C to stop the shell.
  • Switch back to the Storybook terminal if it is still running.
  • Press Ctrl+C to stop Storybook.

The static Pages deployments remain online without using local compute. The micro-frontend-performance-dashboard folder stays available for your return.

Delete - I don't want to use this again

This cleanup is permanent. You remove each resource separately, so you stay in control of every confirmation.

Stop the local processes:

  • Return to the Visual Studio Code terminal running the local shell.
  • Press Ctrl+C to stop the shell.
  • Switch back to the Storybook terminal if it is still active.
  • Press Ctrl+C to stop Storybook.
  • Close Visual Studio Code.

Delete the analytics remote project:

  • Return to the Cloudflare dashboard from earlier.
  • Select Workers & Pages.
  • Select the Pages project serving the analytics remote.
  • Select Settings.
  • Select Delete project.
  • Complete the confirmation prompt.

The analytics remote project is now removed with its deployments.

Delete the shell project:

  • Return to Workers & Pages.
  • Select the Pages project serving the final shell.
  • Select Settings.
  • Select Delete project.
  • Complete the confirmation prompt.

The shell project is now removed with its deployments. Both public pages.dev URLs no longer serve the dashboard.

Delete the local workspace:

  • Use the Windows file browser to return to the folder containing micro-frontend-performance-dashboard.
  • Select the micro-frontend-performance-dashboard folder.
  • Press Shift+Delete to remove the folder permanently.
  • Complete the deletion confirmation.

The parent folder should no longer list micro-frontend-performance-dashboard. Your local source files plus installed dependencies are now removed.

Nice Work!

Nice Work!

You did it! Your React micro-frontend dashboard now loads its independently deployed analytics panel on demand.

You've learned how to:

  • Built an independently runnable analytics remote with its own Revenue pulse interface. Documented the same component in Storybook without launching the shell.
  • Configured Webpack Module Federation with unique build identities and singleton React sharing. Deployed the host and remote to separate Cloudflare Pages projects.
  • Experienced the cost of an eager remote download before replacing it with lazy loading through Suspense. Proved the improvement with load timing and request-count measurements.
  • Completed the optional Secret Mission by adding a React Error Boundary that contains a failed remote import while the rest of the dashboard stays usable.

Ready to quiz yourself?