This page takes you from nothing to a federated plugin rendering inside a host shell. It assumes
React with Rspack (or Webpack 5) and a package manager that can run create.
Steps 1–4 build the plugin; step 5 builds the host. If you only own one side, skip to
Build a plugin or
Build a shell. Either way, do
Installation first — the packages are on a
private registry and pnpm create z0-plugin fails without a token.
Here is the end state, running — a real Rspack host loading two remotes it never imported at build time. Everything below builds up to this:
basic_plugin (rspack)vite_plugin (vite)Prefer to read the finished code?
The library repo ships examples/basic-plugin (remote) and examples/basic-shell (host) — the two
halves of this quickstart, runnable side by side. See
Examples.
1. Scaffold a plugin#
pnpm create z0-plugin my-plugin
cd my-plugin && pnpm installYou are prompted for a dev port, the host shell's port, a vendor name and a description. What lands on disk is a working remote:
my-plugin/
├── rspack.config.ts # MF remote; exposes ./init + ./HomePage, DTS generation on
├── manifest-entry.json # the host appends this to its mfes.json
└── src/
├── init.tsx # registers a route + nav item, cleans up on unmount
├── HomePage.tsx # your federated page
├── main.tsx # standalone entry
└── core/
├── LocalContext.ts # fail-loud stub of the host store
└── useShared.tsx # createPluginStoreBridge wire-up2. Run it with no host#
pnpm dev:standalone # IS_STANDALONE=true → the local stub contextOpen http://localhost:3500. There is no shell running: the store bridge resolved to
LocalContext, whose stub throws on any key you read but never stubbed — so standalone drift
surfaces the moment you introduce it, not weeks later under the real host.
3. Register what the plugin contributes#
src/init.tsx is loaded once per boot by the host. Register in an effect, clean up on unmount:
import { useEffect, type ReactNode } from "react";
import HomePage from "./HomePage";
// Hand-written on purpose — see "What the host hands you" in Build a plugin for why.
type RegistryHandle<TItem extends { id: string }> = {
addItem: (item: TItem) => void;
removeItem: (id: string) => void;
};
type InitProps = {
values?: { useAuth: () => { user: { id: string } | null; isLoading: boolean } };
registries?: {
routes?: RegistryHandle<{ id: string; path?: string; element?: ReactNode }>;
nav?: RegistryHandle<{ id: string; title?: string; url?: string; order?: string }>;
settings?: RegistryHandle<{ id: string; title?: string; element?: ReactNode }>;
};
};
export default function Init({ registries }: InitProps) {
useEffect(() => {
// Ids are auto-prefixed with your remote name on the host side — keep them short and local.
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;
}Why the cleanup matters
Hosts re-mount the plugin tree on context switches (org switch, re-auth). Without the cleanup return, every switch leaves a stale route and a duplicate nav item behind.
4. Declare the host contract#
The generated manifest-entry.json is what the host copies into its manifest:
{
"name": "my-plugin",
"service": {
"id": "my_plugin",
"remoteEntry": "http://localhost:3401/remoteEntry.js"
},
"version": "0.0.1",
"shellApiVersion": "^0.1.0",
"vendor": "Acme Corp",
"description": "Acme's analytics dashboard plugin",
"permissions": ["registry:nav", "registry:router"],
"enabled": true
}service.id is the Module Federation remote name — the host loads ${id}/init from it. Full field
list in the manifest reference.
5. Wire the host shell#
Three moving parts: a store, a loader, a provider stack.
import { defineSharedStore } from "@z0devs/mf-shell-kit";
import { NavRegistry, SettingsRegistry, ComponentRegistry } from "@z0devs/mf-registry";
import { useAuth } from "../auth";
export const sharedStore = defineSharedStore({
// No `apiVersion` — it defaults to SHELL_API_VERSION, which is what the plugin above targets.
values: { useAuth }, // hooks + helpers plugins may call
registries: {
nav: NavRegistry.api,
settings: SettingsRegistry.api,
components: ComponentRegistry.api,
},
// Library-owned registries: `router` injects `routes`, `onboarding` injects `onboardingSteps`
// (and auto-mounts <OnboardingHost/>).
features: { onboarding: true, router: true },
});
// The plugin-kit bridge calls the default export as a hook.
export default sharedStore.useShellSharedStore;import { PluginRouteLoader } from "@z0devs/mf-shell-kit";
import { type RouteObject } from "react-router-dom";
import { sharedStore } from "./store/sharedStore";
import { useAuth, makeAuthAdapter } from "./auth";
import { AppLoader } from "./ui";
const loadManifest = async () => {
const res = await fetch("/mfes.json", { credentials: "include" });
if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`);
return res.json();
};
const ShellRouteLoader = () => (
<PluginRouteLoader
shell={sharedStore}
auth={makeAuthAdapter(useAuth())}
manifest={loadManifest}
loader={AppLoader}
config={{ versionPolicy: "block" }}
/>
);
export const routes: RouteObject[] = [
{
path: "/",
element: <RootLayout />,
children: [
{
element: <ShellRouteLoader />,
children: [
{ index: true, element: <Home /> },
// plugin routes are appended here at runtime
],
},
],
},
];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";
// Remotes start empty — PluginRouteLoader registers each manifest entry at boot.
init({ name: "shell", remotes: [], plugins: [createRuntimePlugin({ name: "shell" })] });
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<MFEnvProvider mfeEnvNamespace="__MFE_ENV__">
<RouterContextProvider initialRoutes={routes} defaultErrorElement={<ErrorPage />}>
<Router />
</RouterContextProvider>
</MFEnvProvider>
</QueryClientProvider>
);
}The shell also has to expose its store so plugins can bind to it:
import { z0Federation } from "@z0devs/mf-config";
new ModuleFederationPlugin(
z0Federation({
name: "shell",
exposes: { "./sharedStore": "./src/store/sharedStore.tsx" },
router: true, // also share react-router-dom
}),
);z0Federation emits the share map, filename and the dts block. The share map is the part worth
having generated: alongside react / react-dom it includes the subpath entrypoints
(react/jsx-runtime, react/jsx-dev-runtime, 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
gives a remote its own React and the failure is Cannot read properties of null (reading 'useEffect').
It returns plain options data, so { ...z0Federation({…}), shareStrategy: "loaded-first" } overrides any
field, and importing only z0Shared() while hand-writing the rest of the block is equally supported.
6. Run both sides#
# terminal 1 — the host
pnpm dev # http://localhost:3400
# terminal 2 — the plugin, federated (not standalone)
pnpm dev # http://localhost:3401/remoteEntry.jsAdd the plugin's manifest-entry.json to the host's mfes.json, reload the shell, and your nav
item plus /my-plugin route appear. The host never imported your code at build time.
7. Test it without a host#
import { createMockShellStore, renderWithMockShell } from "@z0devs/mf-testing";
const shell = createMockShellStore({
values: { useAuth: () => ({ user: { id: "u-1", email: "alice@example.com" }, isLoading: false }) },
});
const { getByText } = await renderWithMockShell(<MyPlugin />, { shell });
expect(getByText("alice@example.com")).toBeInTheDocument();Next#
The full plugin-author contract: props, exposes, lifecycle, pitfalls.
Build a shellHost wiring in depth — manifest hosting, policies, error sinks, curation.
The shared storeHow values and registries cross the bundle boundary, and how to type the slice.
TroubleshootingDuplicate React, RUNTIME-008, ChunkLoadError, blocked versions, deadlocked graphs.