Module Federation is solved. Plugin hosts are not.

Why runtime micro-frontends still take months to get right, and what the ZoneZero Module Federation toolkit does about it.

Shadi ShaheenZoneZero8 min read
Module FederationMicro-frontendsArchitectureReact

Every team that adopts Module Federation has the same first week. You add ModuleFederationPlugin, expose a component from one build, load it from another, and it works. It genuinely works — first try, in about an hour. You tell everyone micro-frontends are easy now.

Then you try to build a product on it, and the next six months are spent writing the part nobody warned you about.

What Module Federation actually gives you#

Two primitives, both excellent:

  1. Load a module from another build at runtime. No shared repo, no shared deploy, no rebuild of the host when the remote changes.
  2. Share a dependency between builds. One React instance across bundles, negotiated by version at load time.

That's it. That's the correct scope for a bundler feature, and the official @module-federation/runtime does it well across Webpack, Rspack and Vite.

The trouble is that "load a module at runtime" is about 10% of what a plugin host needs.

The other 90%#

Here is the list every platform team ends up writing by hand. It is remarkably consistent across companies, which is the tell that it should be a library.

Which remotes exist? The remotes map in your bundler config is build-time. If plugins are build-time, you don't have plugins — you have modules with extra network hops. Real plugin hosts need a manifest: data, fetched at boot, per tenant, editable without a host release.

Is this plugin compatible with this shell? loadRemote() either resolves or throws. A plugin built against last quarter's host API will load happily and then explode when it calls a hook you deleted. You need a version axis with a policy — and a decision about whether an incompatible plugin is blocked, warned about, or ignored.

How does a plugin add a route? Nothing in Module Federation models this. So you invent a protocol. Then you invent four more for nav items, settings panels, toolbar buttons and modals. Then two plugins both register id: "home" and you invent namespacing. Then a third plugin needs to render a component owned by the second, and you invent a component registry.

How does a plugin read the session? You export a store from the host and hope both sides agree on its shape. There is no compile-time contract between two independently built bundles unless you deliberately create one — so the failure mode is a production undefined is not a function on a route only one customer visits.

How does a plugin author work? If your plugin can only run inside the host, every plugin author runs your entire platform locally: shell, backend, database, auth. Onboarding takes a week, and "works on my machine" becomes structural.

What happens when a remote's script 404s mid-boot? Unhandled rejection, blank screen, no telemetry. A stale-deploy chunk failure is not an edge case; it's Tuesday.

Which plugins loaded, how long did they take, which failed and why? With no answer to that, the first production incident involving a plugin is unfalsifiable — everyone blames the remote nobody owns.

Was that remoteEntry.js the build you reviewed? If partners self-host their remotes, you are executing bytes from someone else's CDN in your users' sessions. You want at least tamper-detection.

None of these are Module Federation's job. All of them are the same job in every product that does this — which is exactly why we extracted them.

What the ZoneZero toolkit adds#

@z0devs/* sits on top of the official runtime — it re-implements none of it — and fills in the layer above.

Remotes become data#

{
  "name": "acme-analytics",
  "service": { "id": "acme_analytics", "remoteEntry": "https://acme.com/plugins/analytics/remoteEntry.js" },
  "version": "0.5.0",
  "shellApiVersion": ">=0.1.0 <0.2.0",
  "vendor": "Acme Corp",
  "permissions": ["registry:nav", "network:external"],
  "enabled": true
}

The host fetches MFEManifestEntry[] at boot — static file, per-tenant endpoint, feature-flagged filter, whatever you like. Every entry is schema-validated; malformed ones are skipped and reported, not silently dropped. Adding a plugin is a row in a manifest, not a release of the shell.

One version axis that gates loading#

The shell advertises an apiVersion. Each plugin declares the range it works with. Incompatible plugins never execute:

<PluginRouteLoader
  shell={sharedStore}
  manifest={loadManifest}
  loader={AppLoader}
  config={{ versionPolicy: "block" }}   // or "warn" during a vetted upgrade window
/>

Plugin-to-plugin requirements get the same treatment: declare dependencies: { billing: "^2.1.0" } and an unmet requirement blocks the plugin with a dependency-unmet record instead of a mystery crash three screens deep.

Note what this is not: it isn't a re-implementation of shared-library versioning. React and your design system stay where they belong — Module Federation's shared config. The manifest models the axis MF has no opinion about: plugin units and the host API they were built against.

Contributions are typed, namespaced registries#

Routes, nav items, settings entries, toolbar slots, cross-plugin components and onboarding steps all flow through the same primitive:

export default function Init({ registries }) {
  useEffect(() => {
    registries.routes.addItem({ id: "home", path: "/my-plugin", element: <HomePage /> });
    registries.nav.addItem({ id: "home", title: "My Plugin", url: "/my-plugin", order: "m" });
 
    return () => {
      registries.routes.removeItem("home");
      registries.nav.removeItem("home");
    };
  }, [registries]);
 
  return null;
}

Two plugins can both use id: "home"; the host stores them as my_plugin:home and other_plugin:home. Cleanup is a removeItem call, so an org switch that re-mounts the tree doesn't leave ghost routes behind.

The part I'd call genuinely differentiating: hosts and remotes don't get the same API. The host keeps the full registry (emit, setActiveFlags, clearRemote); a remote receives an append-only, id-scoped view. One plugin cannot clear another's contributions or hijack the shared event bus by accident. Not a security boundary — a cooperation boundary, which is what you actually need between teams shipping into one app.

A store contract both sides type-check against#

The host declares it once:

export const sharedStore = defineSharedStore({
  values: { useAuth, useGlobalStore, contextualApiFetch },
  registries: { nav: NavRegistry.api, components: ComponentRegistry.api },
  features: { router: true, onboarding: true },
});

The plugin narrows it to exactly what it uses:

type Slice = SelectSharedStoreSlice<
  ReturnType<typeof useShellSharedStore>,
  {
    values: { useAuth: true; useGlobalStore: "currentOrganization" };
    registries: { components: true };
  }
>;

Read a value you didn't declare, and it's a type error. Delete a value from the host that a plugin declared, and that plugin's build fails. The contract is checked where it's cheap to fix.

Plugins run without the host#

IS_STANDALONE=true pnpm dev

The bridge resolves to a local stub context instead of the federated store. And the stub is fail-loud — a Proxy that throws missing local stub for "useBilling" the moment you read something you never stubbed. Standalone mode that silently diverges from production is worse than no standalone mode; this one can't.

The bridge itself is one of the more interesting bits of engineering in the library. It starts loading the host store eagerly, single-flight, at factory call, and exposes a promise that never rejects — every failure path binds the local fallback and resolves. That makes it safe to gate the module graph on:

const bridge = createPluginStoreBridge({ loadShellModule, LocalContextFallback });
await bridge.ready;                 // top-level await
export const { useShellSharedStore } = bridge;

With that one line, every consumer — including components you publish into the host's registry and other MFEs render outside your tree — calls useShellSharedStore() synchronously. No provider nesting, no Suspense boundaries, no loading flash. For bundlers or entry points that can't do top-level await, the same bridge hands you a provider and a HOC.

The operational details that decide whether it survives production#

  • Retry that matches reality. Transient script failures, script timeouts and — added after a real incident — ChunkLoadError, all retried with tunable attempts and backoff. CSP violations, 404s and parse errors fail fast, because retrying those is theatre.
  • ESM remotes. The MF runtime can't infer whether a remoteEntry is a classic script or an ES module; it must be declared at registration. Declaring a Vite-built plugin is a one-line manifest edit: "service": { …, "type": "module" }.
  • Integrity. An optional remoteEntryHash is verified with crypto.subtle before registerRemotes — a swapped bundle from a partner CDN fails closed with integrity-violated and no retry.
  • Lifecycle you can query. Every plugin moves through discovered → loading → ready | failed | disabled | blocked, with durations. getPluginMetrics() gives you a flat record to push into OpenTelemetry, Prometheus or Datadog; <PluginDebugPanel/> renders the same data for humans.
  • Prefetch. Warm a plugin's bundle on hover or when the browser is idle, idempotent, errors swallowed.

It's a library, not a framework#

Nine packages. The four primitives — context, router, registry, core — have zero dependencies on each other:

pnpm add @z0devs/mf-router          # just the dynamic router
pnpm add @z0devs/mf-plugin-kit      # just the plugin-side bridge
pnpm add @z0devs/module-federation  # or the whole kit, one install

Take the dynamic router into an app with no plugins yet. Take the registries without the loader. Adopt PluginRouteLoader when you're ready. Nothing forces you to swallow the whole thing.

What it deliberately doesn't do#

This is the part most "plugin platform" pitches skip, so let's be blunt.

Plugins run in-process. Same React tree, same origin, same window, same session. The library gives you curation, version gating and supply-chain tamper detection. It does not give you isolation. permissions in the manifest is review metadata for a human curator, not a runtime capability grant. Sandboxing, manifest signing and adversarial multi-tenant separation are explicit non-goals.

The honest framing: this is a trusted-plugin toolkit. Onboard your own teams and vetted partners, and treat plugin code as first-party-equivalent. If you need to run code you wouldn't run as first-party, you need an out-of-realm boundary — iframes or workers with postMessage — and a different product.

Saying so costs us nothing and saves you an architecture review. A library that implies isolation it doesn't provide is worse than one that draws the line clearly.

Where this came from#

None of this is speculative design. It was extracted from a production platform where product teams ship independently deployable slices of one app, and every item on that "other 90%" list was a real incident, a real onboarding complaint, or a real code review comment before it became an API.

That's also why the boring parts exist — the retry matcher, the Vary: Origin note in the hosting guide, the warning that a shell store importing back into a remote deadlocks two async module graphs. Those aren't things you design up front. They're things you learn, and then write down so nobody has to learn them twice.

Start here#