A host shell is three files plus a manifest. This guide walks each one, then covers the operational decisions: where the manifest lives, what happens when a plugin is incompatible, and how failures reach you.
1. sharedStore.ts — declare the contract#
The store is the only sanctioned channel between host and plugins. Everything a plugin may call
goes in values; everything a plugin may contribute goes in registries.
import { createEventsEmitter } from "@z0devs/mf-context";
import {
ComponentRegistry,
NavRegistry,
SettingsRegistry,
ToolbarRegistry,
} from "@z0devs/mf-registry";
import { defineSharedStore } from "@z0devs/mf-shell-kit";
import { useAuth, type User } from "../auth";
import useGlobalStore from "../store/global";
import { contextualApiFetch } from "../clients/contextualApiFetch";
export type ShellEvents = {
onAuthChange: { user: User | null };
onOrgChange: { orgId: string };
};
const events = createEventsEmitter<ShellEvents>();
export const sharedStore = defineSharedStore({
// `apiVersion` is omitted on purpose — it defaults to SHELL_API_VERSION from @z0devs/mf-core,
// which is what the plugin templates already target. Set it only to pin deliberately.
values: { useAuth, useGlobalStore, contextualApiFetch },
registries: {
nav: NavRegistry.api,
settings: SettingsRegistry.api,
toolbar: ToolbarRegistry.api,
components: ComponentRegistry.api,
},
// Library-owned registries, injected for you: `router` adds `routes`, `onboarding` adds
// `onboardingSteps` and auto-mounts <OnboardingHost/>.
features: { onboarding: true, router: true },
events,
});
// The plugin bridge calls the federated default export as a hook.
export default sharedStore.useShellSharedStore;Three audiences, three views of the same store:
| Caller | Access | Surface |
|---|---|---|
| Host code | sharedStore.registries.nav, sharedStore.values | full RegistryApi — emit, setActiveFlags, clearRemote, add/remove/get |
| Plugin components | sharedStore.useShellSharedStore() | read view, registries narrowed to PluginRegistryApi |
Plugin init | sharedStore.useInitProps(remoteName) (called for you by PluginRouteLoader) | same narrowed API, ids auto-prefixed with the remote name |
2. routes.tsx — mount PluginRouteLoader#
import { PluginRouteLoader } from "@z0devs/mf-shell-kit";
import { type RouteObject } from "react-router-dom";
import { sharedStore } from "./store/sharedStore";
import { useAuth, useAuthActions } from "./auth";
import { AppLoader } from "./ui";
import { reportToTelemetry } from "./telemetry";
const loadManifest = async () => {
const res = await fetch("/config/mfe/mfes.json", { credentials: "include" });
if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`);
return res.json();
};
function AppPluginRouteLoader() {
const { user, isLoading } = useAuth();
const { login, logout } = useAuthActions();
return (
<PluginRouteLoader
shell={sharedStore}
auth={{
user: user ?? null,
isLoading,
login: (returnTo) => login(returnTo ?? window.location.pathname),
logout,
}}
manifest={loadManifest}
loader={AppLoader}
onPluginError={reportToTelemetry}
config={{ versionPolicy: "block" }}
/>
);
}
export const routes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
children: [
{
element: <AppPluginRouteLoader />,
children: [
{ index: true, element: <Home /> },
// plugin routes are appended here at runtime
],
},
],
},
];PluginRouteLoader mounts the store Provider and the InitPropsProvider internally — you do not
wire those yourself. What it does per boot:
Fetch and validate
loadManifest() (cached through React Query), then validateManifestEntry() on every record.
Invalid entries are skipped and reported as manifest-invalid.
Filter
enabled !== false, then shellApiVersion against shell.apiVersion under your versionPolicy,
then inter-plugin dependencies.
Load
One <InitRemote> per surviving entry: optional integrity check, registerRemotes, then
loadRemote("${service.id}/init") with retry and timeout.
Gate
The loader component stays on screen until every plugin reports ready, so users never see a
half-built nav.
Track
Every transition lands in PluginRuntimeRegistry: discovered → loading → ready | failed | disabled | blocked.
3. app.tsx — the provider stack#
import { init } from "@module-federation/runtime";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MFEnvProvider, createRuntimePlugin } from "@z0devs/mf-core";
import { Router, RouterContextProvider } from "@z0devs/mf-router";
import { routes } from "./routes";
init({ name: "shell", remotes: [], plugins: [createRuntimePlugin({ name: "shell" })] });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<MFEnvProvider mfeEnvNamespace="__MFE_ENV__" shellConfigLoader={() => getShellConfig()}>
<RouterContextProvider initialRoutes={routes} defaultErrorElement={<ErrorPage />}>
<Router />
</RouterContextProvider>
</MFEnvProvider>
</QueryClientProvider>
);
}Prefer one call? createShellApp mounts RouterContextProvider → MFEnvProvider → Router for you:
import { createShellApp } from "@z0devs/mf-shell-kit";
const App = createShellApp({
initialRoutes: routes,
defaultErrorElement: <ErrorPage />,
mfEnv: { mfeEnvNamespace: "__MFE_ENV__", shellConfigLoader: () => getShellConfig() },
AppShell: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
});4. Expose the store over Module Federation#
new ModuleFederationPlugin({
name: "shell",
filename: "remoteEntry.js",
exposes: {
"./sharedStore": "./src/store/sharedStore.tsx",
},
shared: {
// Generated — `z0Shared` includes the react/jsx-runtime, react/jsx-dev-runtime and react-dom/client
// SUBPATHS as well as the package roots. Every participant must share the same set; missing a subpath
// is the classic cause of "Cannot read properties of null (reading 'useEffect')".
...z0Shared({ router: true, packages: ["@tanstack/react-query"] }),
},
dts: { generateTypes: { tsConfigPath: "./tsconfig.json", abortOnError: false }, consumeTypes: false },
});The whole block above is what z0Federation({ name: "shell", exposes, router: true, packages: [...] })
emits, if you'd rather not spell it out:
import { z0Federation } from "@z0devs/mf-config";
new ModuleFederationPlugin(
z0Federation({
name: "shell",
exposes: { "./sharedStore": "./src/store/sharedStore.tsx" },
router: true,
packages: ["@tanstack/react-query"],
}),
);Plugins then import the type with import type ... from "shell/sharedStore" and the runtime
through createPluginStoreBridge. dts.generateTypes emits dist/@mf-types so plugin repos can
point a tsconfig paths entry at your real store types.
Never expose internal stores directly
Expose only the defineSharedStore result. A direct expose of your Zustand store or API client
bypasses the version contract, and every plugin then depends on internals you'll want to refactor.
Hosting the manifest#
[
{
"name": "billing",
"service": { "id": "billing_mfe", "remoteEntry": "https://cdn.example.com/billing/remoteEntry.js" },
"version": "2.4.1",
"shellApiVersion": "^0.1.0",
"vendor": "Internal",
"enabled": true
},
{
"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",
"description": "Sales analytics dashboard",
"permissions": ["registry:nav", "network:external"],
"enabled": true
}
]The manifest does not have to be static — loadManifest is just a function:
- Per tenant: hit an authenticated endpoint with
credentials: "include"and return only what the tenant is entitled to. - Feature-flagged: fetch one manifest, then filter or reorder in
loadManifest. - Staged rollout: flip
enabledper entry; disabled plugins never load.
config.manifestCacheKey controls the React Query cache key when you need per-tenant isolation.
Version policy#
versionPolicy | Behaviour on an incompatible shellApiVersion |
|---|---|
"block" (default) | Skip the entry, log a warning, record version-blocked |
"warn" | Load anyway, log a warning — for a vetted upgrade window |
"off" | No check at all — tests only |
Load tuning#
<PluginRouteLoader
config={{
loadPolicy: {
maxAttempts: 5, // 1 disables retries
backoffMs: (n) => Math.min(2000, 250 * 2 ** n),
isTransient: (e) => /network|timeout|chunk/i.test(e.message),
scriptTimeoutMs: 30_000,
},
settleMs: 150, // default 1000
loaderSize: 48, // default 300
}}
/* shell, auth, manifest, loader */
/>Defaults: 3 attempts, 500 ms × attempt linear backoff, 60 s script timeout. Transient by default
means ScriptNetworkError, timeouts, RUNTIME-008 and ChunkLoadError; CSP violations, 404s and
parse errors fail fast because retrying them is pointless.
Seeing what happened#
import { subscribeToPluginRuntime, getPluginMetrics } from "@z0devs/mf-core";
subscribeToPluginRuntime(() => {
for (const m of getPluginMetrics()) {
if (m.status === "ready" && m.loadDurationMs != null) {
histogram.record(m.loadDurationMs, { plugin: m.id });
} else if (m.status === "failed") {
counter.add(1, { plugin: m.id, error: m.lastError?.kind ?? "unknown" });
}
}
});Drop <PluginDebugPanel /> into a dev-only route for the same data in a table. More in
Observability.
Curation is yours#
The library ships no admin UI, on purpose — the manifest is your data. A curation screen typically:
- reads the manifest server-side and shows
version,vendor,description,icon,permissions - compares each
shellApiVersionagainstsharedStore.apiVersionand flags mismatches - toggles
enabledper entry, persisted to wherever the manifest is served from - surfaces
permissionsas a "this plugin will…" summary for a human to approve
permissions is review metadata
It is not enforced at runtime. The real enforcement points are your manifest endpoint (who gets to add an entry) and your backend (what a plugin's calls are allowed to do).
What not to do#
- Don't bundle
react/react-dom/react-router-dominto the host remoteEntry withoutsingleton: true, eager: true. - Don't expose internal stores directly — always through
defineSharedStore. - Don't load unvetted remote origins in production. Curate server-side.
- Don't leave
onPluginErrorunwired. Without a sink,manifest-invalidanddependency-unmetare console noise nobody reads.