Module Federation gives you two primitives: load a module from another build at runtime, and
share a dependency between builds. Both are excellent, and this library uses them as-is — it
sits on @module-federation/runtime, the official bundler-agnostic loader, and re-implements none
of it.
What it adds is the layer above: the part every plugin host writes by hand.
What you end up building by hand#
Ship a shell that loads micro-frontends at runtime and you will write all of this, in this order, usually twice:
| Problem | What "just Module Federation" gives you |
|---|---|
| Which remotes exist, and where? | A hardcoded remotes map at build time — so adding a plugin means rebuilding the host. |
| Is this plugin compatible with this shell? | Nothing. loadRemote succeeds or throws at runtime. |
| How does a plugin add a route? | Nothing. You invent a protocol. |
| How does a plugin add a nav item, a settings panel, a toolbar button? | Nothing. You invent five more. |
| How does a plugin read the session, the current org, the API client? | Nothing. You export a store and hope both sides agree on its shape. |
| How do two plugins avoid colliding on ids? | Nothing. Prefix by convention and hope. |
| How does a plugin author run their code without your shell? | Nothing. Most teams give up and run the whole platform locally. |
| A remote's script 404s mid-boot — what does the user see? | An unhandled rejection, usually a blank screen. |
| Which plugins loaded, how long did they take, which failed and why? | Nothing. |
Was that remoteEntry.js the build you reviewed? | Nothing. |
None of these are Module Federation's job. They are all your job — and they are the same job in every product that does this.
What the library adds#
A manifest as the source of truth
Remotes are data, not build config: the host fetches MFEManifestEntry[] at boot, per tenant if it
wants. Every entry is schema-validated; invalid ones are skipped and reported instead of failing
mysteriously. Adding a plugin is a manifest row, not a host release.
One version axis that actually gates loading
The shell advertises apiVersion; each plugin declares a shellApiVersion range. Incompatible
plugins are blocked (or warned, or ignored) by policy — before their code runs. Plugin-to-plugin
requirements get the same treatment through dependencies / optionalDependencies.
Contributions as typed, namespaced registries
Routes, nav items, settings entries, toolbar slots, cross-plugin components and onboarding steps all
flow through registries. Ids are auto-prefixed per remote, so id: "home" from two plugins can
never collide. Hosts keep the full registry API; remotes get an append-only view.
One store contract, typed from both ends
defineSharedStore on the host, createPluginStoreBridge on the plugin, SelectSharedStoreSlice
to narrow the plugin's view to exactly the slots it uses. If the host removes a value your plugin
reads, the plugin's build fails — not its production runtime.
Standalone development that fails loudly
A plugin runs with IS_STANDALONE=true against a stub context. Read a shell value you never stubbed
and it throws immediately, with the key name — so the stub can't silently drift from the real
contract.
Operational behaviour you'd otherwise bolt on
Retry with backoff for transient script and chunk failures, script timeouts, per-plugin lifecycle
records and load timings, a typed error union with one sink, SRI-style integrity checks on
remoteEntry.js, hover/idle prefetch, and a debug panel.
What it deliberately does not add#
Not a sandbox
Plugins run in-process. There is no isolation, and permissions in the manifest is review
metadata for a human curator, not a runtime capability grant. Manifest signing and adversarial
multi-tenant separation are explicit non-goals — see
Trust model.
It also does not re-model shared npm dependency versions. React, your design system and friends
are negotiated by Module Federation's own shared config (singleton, requiredVersion,
strictVersion). The library only adds the plugin-level axis MF has no opinion about.
Composability over framework#
Ten packages, four of which are primitives that stay out of each other's way — mf-context,
mf-registry and mf-core depend on none of the others, and mf-router depends only on mf-registry,
because routes are a registry:
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 installTake the dynamic router into an app that has no plugins yet. Take the registries without the
loader. Take the loader when you're ready for it. The opinionated pieces — PluginRouteLoader,
defineSharedStore — are two packages you can opt into, not a framework you have to adopt.
When you should not reach for it#
- You have one frontend team and one deploy. Runtime federation buys you nothing; use route-level code splitting.
- You need to run untrusted third-party code. You need an out-of-realm sandbox instead.
- You are not on React. The runtime underneath is framework-agnostic; this library's API is not.