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

Quickstart

Scaffold a plugin, run it without a host, then load it in a shell.

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:

The finished result — host + two pluginsOpen in a tab ↗
Remotes loaded at runtime: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 install

You 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-up

2. Run it with no host#

pnpm dev:standalone     # IS_STANDALONE=true → the local stub context

Open 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:

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

manifest-entry.json
{
  "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.

src/store/sharedStore.tsx
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;
src/routes.tsx
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
        ],
      },
    ],
  },
];
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";
 
// 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:

rspack.config.ts (host)
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.js

Add 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#