DEV Community

Purneswar Prasad
Purneswar Prasad

Posted on

A story on Frontend Architectures - SPA meets the enterprise! (Part-2)

(... In Part-1, we established the foundation with definition of MFE and the basic of Module Federation.)

Let's understand how the remoteEntry.js file exposes a remote app from the shell with some real code.

We'll do this in 3 layers:

1) Remote app config
2) What remoteEntry.js exposes
3) Shell app runtime code

1) Remote app Module Federation config

//mfe/webpack.config.js

const {ModuleFederationPlugin} = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "cartApp"            //global container name
      filename: "remoteEntry.js" //this will be loaded by the shell
      exposes: {
        "./CartWidget" : "./src/CartWidget.js"  //exposed metadata
      shared: {
        react: {singleton: true },
        "react-dom": {singleton: true }
      }
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

Let's understand the plugin keys exposes and shared.

  • exposes:

    • Purpose: declares what modules this remote makes available to others
    • Use small composable public names like ./Header, ./routes
    • Keep internal implementation hidden and only expose contracts.
  • shared:

    • Purpose: negotiate shared dependencies and avoid duplicate of shared libraries like React.
    • Implemented as a per-module shared scope where providers(remote) register available versions and consumers(shell) request versions
    • singleton: true ensures a single instance across containers (use for React, React DOM etc.)
    • requiredVersion allows consumer to express the semantic version it expects
    • strictVersion fails the negotiation if the version cannot be satisfied (dangerous for progressive migration).
    • eager: true loads the shared module immediately (useful for small libs), but increases initial payload.

So, with this config, the remote deploys remoteEntry.js and exposes the module, but can still build independently.

2) What remoteEntry.js exposes

After remoteEntry.js loads in browser, Webpack registers a container(a JS object) under a global name which looks like this:

window.cartApp = {
  // metadata about exposed modules
  get: async (moduleName) => {
    // returns a FACTORY, not the module itself
  },

  init: async (shareScope) => {
    // registers shared dependencies into global scope
  }
};
Enter fullscreen mode Exit fullscreen mode

3) Shell app runtime code

The remoteEntry file can be loaded on the host in 2 different ways: static and dynamic.

  • Dynamic loading

Shell webpack config

// shell/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      remotes: {}, // 👈 NOTHING here
      shared: {
        react: { singleton: true },
        "react-dom": { singleton: true }
      }
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

Here the host bundle has no knowledge of the remotes.

Let's look at the the core of the dynamic federation, runtime loader

loadRemote.js

// shell/src/loadRemote.js

//idempotent script injection 

export function loadRemoteEntry(remoteUrl, scope) {
  return new Promise((resolve, reject) => {
    if (window[scope]) return resolve(); // already loaded

    const script = document.createElement("script");
    script.src = remoteUrl;
    script.type = "text/javascript";
    script.async = true;

    script.onload = () => {
      console.log(`${scope} remote loaded`);
      resolve();
    };

    script.onerror = () => {
      reject(new Error(`Failed to load remoteEntry: ${remoteUrl}`));
    };

    document.head.appendChild(script);
  });
}

// turns the loaded remote app into an actual usable module

export async function loadRemoteModule(remoteUrl, scope, module) {
  // 1. Load remoteEntry.js
  await loadRemoteEntry(remoteUrl, scope);

  // 2. Initialize shared scope
  await __webpack_init_sharing__("default");

  // 3. Initialize the remote container
  const container = window[scope];
  await container.init(__webpack_share_scopes__.default);

  // 4. Get module factory
  const factory = await container.get(module);

  // 5. Execute factory
  return factory();
}
Enter fullscreen mode Exit fullscreen mode

The first function loads a dynamic remoteEntry.js file by doing a script injection(ensures that a remote’s remoteEntry.js is loaded exactly once, even if multiple parts of the application attempt to load it, preventing duplicate execution and runtime conflicts) & the second function makes the remote usable by exposing the module.

Let's understand the details of the second function.

  • await loadRemoteEntry(..) loads the remoteEntry file using the remoteUrl produced during the build

  • __webpack_init_sharing__ and __webpack_share_scopes__ are provided by Webpack's runtime.

  • await __webpack_init_sharing__('default') ensures host shares are initialized

  • const container = window[scope] is the container registered by remoteEntry

  • container.init(...) registers the remote's shared modules

  • __webpack_init_sharing__('default') must be called before container.init(...) to ensure host registered share scope exists, preventing undefined scope issues

In simple words:

  • Host says: “Here is the shared dependency table”
  • Remote says: “Cool, I’ll register what I can share”
  • Webpack decides:

    • which React version to use
    • ensures singleton rules
  • container.get(module) returns a factory, which on invoked returns the actual module(component/object)

  • Static loading

Shell application declares the remote at build time

// shell/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "hostApp",
      remotes: {
        // 👇 STATIC binding (known at build time)
        cartApp: "cartApp@http://localhost:3001/remoteEntry.js"
      },
      shared: {
        react: { singleton: true },
        "react-dom": { singleton: true }
      }
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

Here the **remotes** property helps the host reference an external container.

The host already knows the remote name and URL and Webpack bakes the remote mapping into the host bundle.

Then, it can be imported like a local module

// shell/src/App.js
import CartWidget from "cartApp/CartWidget";

export default function App() {
  return (
    <div>
      <h1>Host Application</h1>
      <CartWidget />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Important things to note:

  • This import is not resolved at build time
  • It is transformed by Webpack into a runtime fetch from remoteEntry
  • Webpack automatically performs shared scope initialisation, container.init(), container.get() and factory execution that we saw earlier in dynamic loading behind the scenes

So basically, in the background the import is rewritten by Webpack to this:

await __webpack_init_sharing__("default");
const container = await loadRemoteContainer("cartApp");
await container.init(__webpack_share_scopes__.default);
const factory = await container.get("./CartWidget);
const CartWidget = factory();
Enter fullscreen mode Exit fullscreen mode

Practical MFE wiring architecture patterns

1) Simple host + static remotes

  • Use when remote URLs are stable and managed tightly
  • Shell webpack remotes points to fixed URLs
  • Low runtime complexity but also, low deployment flexibility

2) Host + dynamic remotes via manifest

  • Shell at runtime fetches a manifest JSON mapping remote name to URL
  • Host uses the loader to inject remoteEntry.js
  • Gives per-environment control and allows independent deployments and releases.

3) Single-spa for lifecycle + Module Federation for sharing

4) SSR with Module Federation

Practical checklist for converting a monolith

  1. Pick a simple host that will control routing and layout
  2. Identify vertical slices(a single page or a low traffic route) as the first remote
  3. Make the shell load remoteEntry dynamically from a staging manifest
  4. Declare shared dependencies as singletons
  5. Smoke test integration of shell and remotes
  6. Ensure remotes publish a small contract file(exposed modules and versions) & shell CI validates compatibility
  7. Feature-flag route to new remote rollout
  8. Iterate the process by extracting more MFEs and investing in platform automation

To wrap it up, micro-frontends are not a silver bullet, but an organisational scaling tool disguised as a frontend architecture.
When teams, domains, and deployment velocity outgrow a single SPA, MFEs combined with Webpack Module Federation provide a pragmatic way to regain autonomy without sacrificing runtime cohesion. Used thoughtfully, they shift frontend engineering from “shipping bundles” to composing systems at runtime.
Used prematurely, they simply move complexity around and make your life harder.

As with most architectural decisions, the real win comes not from the tooling, but from aligning it with organisational structure, ownership, and long-term business scale.

Top comments (0)