Four 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. Then
two kits that compose them, a build-time config helper, a test harness, a meta package and a CLI — ten in
all. Install a slice or the whole thing.
pnpm add @z0devs/module-federation \
react react-dom react-router-dom eventemitter3 react-error-boundary \
@module-federation/runtime @tanstack/react-query@z0devs/mf-context#
Cross-bundle singleton React context and a typed event bus.
import { createMFSharedContext, createEventsEmitter, useListenerContext } from "@z0devs/mf-context";
const shared = createMFSharedContext({ useAuth, useGlobalStore });
export const { MFSharedContextProvider, useMFSharedContext } = shared;
type Events = { "user:login": { id: string }; "user:logout": void };
const bus = createEventsEmitter<Events>();
bus.addListener("user:login", (e) => console.log(e.id));
bus.emit("user:login", { id: "u_1" });useListenerContext(bus, ["tick"]) re-renders a component when any listed event fires.
Peers: react >=18, eventemitter3 ^5 · Standalone: yes · Tier: stable
@z0devs/mf-router#
Dynamic React Router host. Routes live in a registry; the provider rebuilds the router when it changes.
import {
RouterContextProvider,
Router,
RouteRegistry,
routeRegistryApi,
useRouteRegistry,
useRouterContext,
type RouteItem,
} from "@z0devs/mf-router";RouteItem is a react-router RouteObject plus id (required) and optional order / flags, so
loader, action, children, errorElement and lazy all work. The provider injects registered
routes at initialRoutes[0].children[0].children.
Peers: react >=18, react-dom >=18, react-router-dom >=6.4 <8, @z0devs/mf-registry ·
Standalone: yes · Tier: stable
@z0devs/mf-registry#
Namespaced contribution registries — a factory plus five built-ins.
import {
createRegistryContext,
withRemoteNamespace,
toPluginRegistry,
NavRegistry,
SettingsRegistry,
ToolbarRegistry,
ComponentRegistry,
OnboardingStepsContext,
onboardingStepsApi,
useOnboardingStepsRegistry,
type RegistryApi,
type PluginRegistryApi,
type RegistryItem,
type NavItem,
type SettingsItem,
type ToolbarItem,
type DynamicComponent,
type OnboardingStep,
} from "@z0devs/mf-registry";Each built-in exports { Context, api, useRegistry } — pass .api to defineSharedStore. Full
RegistryApi: addItem, removeItem, getItem, getItems, getActiveFlags, subscribe,
setActiveFlags, clearRemote, addListener, removeListener, emit. Remotes get the
PluginRegistryApi subset — everything except setActiveFlags, clearRemote and the emitter.
For rendering, use the hooks rather than getItems() in render, which is stale after any mutation:
import { useRegistryItem, useRegistryItems } from "@z0devs/mf-registry";
const items = useRegistryItems(NavRegistry.api); // re-renders on any listed change
const one = useRegistryItem(ComponentRegistry.api, "a:Chart"); // only when THAT item changesTwo properties of the read API worth knowing: gating is uniform (getItem applies the same flag filter
as getItems, so a hidden item is absent through every path), and getItems() returns a shared
snapshot — the same array every caller and every subscriber reads. Copy before sorting,
[...getItems()].sort(...); sorting it in place silently reorders the registry for everyone else and
leaves the reference unchanged, so React never re-renders to correct it. It is frozen in development
only, so that mistake throws while you're building and costs nothing per-mutation in production —
don't rely on the TypeError to catch it after you ship.
Peers: react >=18, eventemitter3 ^5 · Standalone: yes · Tier: evolving
@z0devs/mf-core#
The Module Federation layer: remote-loading components, manifest schema, integrity, telemetry, prefetch.
import {
BaseRemote, Remote, InitRemote, OnReady,
MFEnvProvider, InitPropsProvider,
createRuntimePlugin,
validateManifestEntry, resolvePluginDependencies,
PluginErrorProvider, usePluginErrorHandler, safeEmitPluginError, pluginErrorSeverity,
usePluginRuntime, getAllPluginRecords, subscribeToPluginRuntime, getPluginMetrics,
prefetchEntry, usePluginPrefetch,
SHELL_API_VERSION,
type MFEManifestEntry, type PluginErrorRecord, type RemoteLoadPolicy,
} from "@z0devs/mf-core";| Component | Role |
|---|---|
BaseRemote | Low-level loader: integrity → register → optional env-config → React.lazy render, with retry, timeout and Suspense fallback |
Remote | BaseRemote inside a react-error-boundary; pass errorFallback |
InitRemote | Loads a remote's init, computing props from InitPropsProvider's useInitProps(name). Silent error boundary |
OnReady | Fires onReady once children mount (used internally) |
| Provider | Role |
|---|---|
MFEnvProvider | Sets the env namespace (default "__MFE_ENV__") and an optional shellConfigLoader deep-merged under each remote's env-config |
InitPropsProvider | Supplies useInitProps(remoteName) to InitRemote |
Peers: react >=18, react-dom >=18, react-error-boundary ^4 || ^5,
@module-federation/runtime >=0.6.0 <3.0.0 · Standalone: yes · Tier: evolving
@z0devs/mf-shell-kit#
The opinionated host side.
import {
PluginRouteLoader,
defineSharedStore,
OnboardingHost,
createShellApp,
PluginDebugPanel,
useLifecycle,
defineSubmodule,
type SharedInitProps, type PluginInitProps, type DefinedSharedStore,
type StoreFeatures, type AuthAdapter, type ManifestLoader,
} from "@z0devs/mf-shell-kit";features: { router: true, onboarding: true } on defineSharedStore injects the routes and
onboardingSteps registries so you don't wire them by hand.
Peers: react >=18, react-dom >=18, react-router-dom >=6.4 <8, @tanstack/react-query >=5,
@z0devs/mf-context >=0.1.0, @z0devs/mf-core >=0.3.0, @z0devs/mf-registry >=0.1.0,
@z0devs/mf-router >=0.1.0 · Standalone: needs siblings · Tier: evolving
@z0devs/mf-plugin-kit#
The plugin side.
import {
createPluginStoreBridge,
createLocalStubContext,
createProxyClient,
type SelectSharedStoreSlice,
} from "@z0devs/mf-plugin-kit";The bridge returns { useShellSharedStore, ready, CombinedStoreProvider, withCombinedStore }. ready
never rejects, which is what makes top-level await safe. Legacy ExtractStateFromHook,
StoreSelectionConfig and SelectStoresDetailed remain exported for pre-{ values, registries }
consumers.
useRegistryItem / useRegistryItems and the plugin-facing types are re-exported here from
@z0devs/mf-registry, so a remote gets them from the one package it already depends on. The hooks are a
runtime re-export, which is why mf-registry is a peer and not just a type-level reference.
Peers: react >=18, @z0devs/mf-registry >=0.2.0 · Standalone: yes · Tier: stable
@z0devs/mf-testing#
import {
createMockShellStore,
createMockAuthAdapter,
mockManifestEntry,
renderWithMockShell,
} from "@z0devs/mf-testing";@testing-library/react is declared an optional peer, so you only need it installed if you use
renderWithMockShell; the rest are required.
Peers: react >=18, @z0devs/mf-context >=0.1.0, @z0devs/mf-core >=0.2.0,
@z0devs/mf-registry >=0.2.0, @z0devs/mf-shell-kit >=1.0.0,
@testing-library/react ^14.0.0 || ^15.0.0 || ^16.0.0 · Tier: experimental — pin exact versions in CI
@z0devs/mf-config#
Build time, not runtime. Emits the shared map and the ModuleFederationPlugin options as plain data
you spread into your own bundler config. Zero dependencies, no React, never reaches a browser bundle.
import { z0Shared, z0Federation } from "@z0devs/mf-config";
new ModuleFederationPlugin(
z0Federation({
name: "my_plugin",
exposes: { "./init": "./src/init.tsx" },
shellEntry: process.env.SHELL_ENTRY,
router: true, // also share react-router-dom
}),
);The value is the subpath entrypoints: alongside react / react-dom it emits react/jsx-runtime,
react/jsx-dev-runtime and react-dom/client, which every participant must agree on. Code compiled with
the automatic JSX runtime imports those directly, so sharing only the package roots leaves a remote with
its own React — the classic Cannot read properties of null (reading 'useEffect').
It returns data, never a plugin instance, so nothing is a one-way door: { ...z0Federation({…}), shareStrategy: "loaded-first", dts: false } overrides any field, and taking only z0Shared() while
hand-writing the rest of the block is a supported path. target: "vite" emits the interdependent settings
an rspack host needs from a Vite remote — see Cross-bundler.
Peers: none · Standalone: yes · Tier: stable
@z0devs/module-federation#
Meta package. One install, everything re-exported, plus subpaths for tighter tree-shaking:
import { PluginRouteLoader, BaseRemote, createPluginStoreBridge } from "@z0devs/module-federation";
import { Router } from "@z0devs/module-federation/router";
import { BaseRemote } from "@z0devs/module-federation/core";
import { createPluginStoreBridge } from "@z0devs/module-federation/plugin-kit";Subpaths: /context, /router, /registry, /core, /shell-kit, /plugin-kit, /config.
/config is the build-time one and is deliberately absent from the root barrel — folding bundler-config
code into index would pull it into every app bundle:
import { z0Federation } from "@z0devs/module-federation/config";The meta package re-exports the others rather than bundling them, so it carries their peers as its own — this is the same list the "Everything" install command at the top of this page installs.
Peers: react >=18, react-dom >=18, react-router-dom >=6.4 <8, eventemitter3 ^5.0.0,
react-error-boundary ^4.0.0 || ^5.0.0, @module-federation/runtime >=0.6.0 <3.0.0,
@tanstack/react-query >=5 · Standalone: yes · Tier: evolving
create-z0-plugin#
pnpm create z0-plugin my-pluginSee the CLI reference.
Picking a set#
| You are… | Install |
|---|---|
| Writing a plugin for someone else's shell | mf-plugin-kit (+ mf-testing in dev) |
| Building a shell from scratch | the meta package, or the five host packages |
| Only after a runtime-registerable router | mf-router + mf-registry |
| Only after contribution registries | mf-registry |
| Only loading a remote component or two | mf-core |
| Any of the above | add mf-config as a devDependency — it only runs in your bundler config |
Start with the meta package
Ship first, then split to individual packages once you know which ones you actually touch — the subpath exports mean bundle size isn't the reason to decide early.