Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Build a shell

Host-side wiring — the shared store, PluginRouteLoader, the provider stack, manifest hosting and load policies.

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.

src/store/sharedStore.tsx
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:

CallerAccessSurface
Host codesharedStore.registries.nav, sharedStore.valuesfull RegistryApiemit, setActiveFlags, clearRemote, add/remove/get
Plugin componentssharedStore.useShellSharedStore()read view, registries narrowed to PluginRegistryApi
Plugin initsharedStore.useInitProps(remoteName) (called for you by PluginRouteLoader)same narrowed API, ids auto-prefixed with the remote name

2. routes.tsx — mount PluginRouteLoader#

src/routes.tsx
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#

src/main.tsx
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#

rspack.config.ts
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#

public/mfes.json
[
  {
    "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 enabled per entry; disabled plugins never load.

config.manifestCacheKey controls the React Query cache key when you need per-tenant isolation.

Version policy#

versionPolicyBehaviour 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 shellApiVersion against sharedStore.apiVersion and flags mismatches
  • toggles enabled per entry, persisted to wherever the manifest is served from
  • surfaces permissions as 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-dom into the host remoteEntry without singleton: 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 onPluginError unwired. Without a sink, manifest-invalid and dependency-unmet are console noise nobody reads.

Next#