You're building a micro-frontend that a @z0devs/* shell loads at runtime. Your code is a separate
build at your own URL; the host loads it, calls your init once so you can register what you
contribute, then renders your pages when the user hits a route you registered.
The five-minute version#
pnpm create z0-plugin my-plugin
cd my-plugin && pnpm install
pnpm dev:standalone # → http://localhost:3500, no host requiredThen, to run under a real shell:
Send the host your manifest entry
The scaffold writes manifest-entry.json. The host appends it to its mfes.json.
Serve your remote
pnpm dev (or deploy) so http://localhost:3401/remoteEntry.js is reachable.
The two dev scripts are separate servers on separate ports, and you pick one per task:
dev (3401) builds the federated remote a host consumes — open it in a browser and you get a
blank page, because it exposes modules rather than an app. dev:standalone (3500) builds the
same plugin as an ordinary app with a stub store, for working on UI with no host running. They use
different ports so a shell pointed at 3401 can never accidentally load the standalone build.
Load the shell
Your route and nav item appear. The host never imported your code at build time.
What the host hands you#
Your init component receives one props object, computed by the host's useInitProps(remoteName):
// A plugin takes these from @z0devs/mf-plugin-kit — the package it already depends on. (A HOST imports
// the same names from @z0devs/mf-registry.) Better still: don't hand-write this shape at all, use
// `Z0InitProps` — see below.
import type { PluginRegistryApi } from "@z0devs/mf-plugin-kit";
type InitProps = {
values: {
// whatever the host declared in defineSharedStore({ values }) — typically:
useAuth: () => { user: User | null; isLoading: boolean };
useGlobalStore: ZustandStore;
contextualApiFetch: (path: string, init?: RequestInit) => Promise<Response>;
};
registries: {
routes: PluginRegistryApi<RouteItem>; // host must enable features.router
nav: PluginRegistryApi<NavItem>;
settings: PluginRegistryApi<SettingsItem>;
toolbar: PluginRegistryApi<ToolbarItem>;
components: PluginRegistryApi<ComponentItem>;
onboardingSteps: PluginRegistryApi<OnboardingStep>;
};
};Why you hand-write this type instead of importing it
The authoritative SharedInitProps and PluginInitProps are exported from
@z0devs/mf-shell-kit — a host package. @z0devs/mf-plugin-kit has no dependency on it (its only
peers are React and @z0devs/mf-registry, for the reactive read hooks), and that independence is the
point: a plugin must not need the shell's build graph to compile. So you declare the slice you actually consume and TypeScript checks it
structurally against whatever the host passes at runtime. Declare less than the host provides and
it still type-checks; declare a field the host doesn't expose and you find out at the boundary, not
three components deep. Every field is optional for the same reason — routes only exists when the
host sets features.router.
Everything under registries is auto-namespaced with your remote id:
registries.nav.addItem({ id: "home" }) lands on the host as my_plugin:home. Use short local ids
and stop worrying about collisions.
The registry view you get is the read + append subset (addItem, removeItem, getItem, getItems,
getActiveFlags, subscribe). Host-only capabilities — setActiveFlags, emit, addListener,
clearRemote — are not on it, so no plugin can wipe another's contributions by accident. Details in
Contributions & registries.
subscribe is what makes reads reactive. Use the hooks rather than calling getItem/getItems in
render, which is correct on first paint and silently stale after any change:
import { useRegistryItem, useRegistryItems } from "@z0devs/mf-plugin-kit";
const widget = useRegistryItem(registries.components, "billing_mfe:InvoiceWidget");useRegistryItem subscribes to that one item, so an unrelated plugin registering fifty nav entries
re-renders nothing.
Don't hand-write the shape of your init props either — import Z0InitProps from
@z0devs/mf-plugin-kit, the package you already depend on:
import type { Z0InitProps } from "@z0devs/mf-plugin-kit";
export default function Init({ registries }: Z0InitProps) { /* … */ }Every registry slot is optional, because which ones exist depends on the host's features — hence
registries?.routes?.addItem(...).
What you give back#
./init — register and clean up#
import { useEffect, type ReactNode } from "react";
import HomePage from "./HomePage";
import SettingsPanel from "./SettingsPanel";
// Same block the Quickstart uses. Declare only the slice you touch — everything is optional
// because the host decides which registries exist (`features.router` adds `routes`, and a shell
// may simply not expose `settings`).
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(() => {
registries?.routes?.addItem({ id: "home", path: "/my-plugin", element: <HomePage /> });
registries?.nav?.addItem({ id: "home", title: "My Plugin", url: "/my-plugin", order: "m" });
registries?.settings?.addItem({ id: "prefs", title: "My Plugin", element: <SettingsPanel /> });
return () => {
registries?.routes?.removeItem("home");
registries?.nav?.removeItem("home");
registries?.settings?.removeItem("prefs");
};
}, [registries]);
return null; // init renders nothing — it only registers
}Always return the cleanup
Hosts re-mount the plugin tree on context switches (org switch, re-auth, tenant change). Without cleanup, each switch leaves stale routes and duplicate nav items behind. This is the single most common plugin bug.
Your pages#
Expose every component the host's router needs to render:
exposes: {
"./init": "./src/init.tsx",
"./HomePage": "./src/HomePage.tsx",
"./SettingsPanel": "./src/SettingsPanel.tsx",
}Reading host state#
Bind once, then call the hook anywhere in your MFE:
import { createPluginStoreBridge } from "@z0devs/mf-plugin-kit";
import LocalContext from "./LocalContext";
const bridge = createPluginStoreBridge({
loadShellModule: () => import("shell/sharedStore"),
LocalContextFallback: LocalContext,
});
// Top-level await: this module — and every importer, including the federated `./init` — finishes
// evaluating only once the store is bound. `ready` never rejects, so it can't poison the graph.
await bridge.ready;
export const { useShellSharedStore } = bridge;import { useRegistryItem } from "@z0devs/mf-plugin-kit";
import { useShellSharedStore } from "./core/useShared";
export default function HomePage() {
const { values, registries } = useShellSharedStore();
const { user, isLoading } = values.useAuth();
// A component another plugin published — reactive, so it appears and disappears with that plugin.
// Above the early return: this is a hook, so it has to run on every render.
const Widget = useRegistryItem(registries.components, "billing_mfe:InvoiceWidget")?.component;
if (isLoading) return <Spinner />;
return (
<main>
<h1>Welcome, {user?.email ?? "guest"}</h1>
{Widget ? <Widget /> : null}
</main>
);
}Full detail — slice types, standalone fallback, the non-top-level-await variants — in The shared store.
Declaring the contract#
{
"name": "my-plugin",
"service": { "id": "my_plugin", "remoteEntry": "https://cdn.example.com/my-plugin/remoteEntry.js" },
"version": "1.2.3",
"shellApiVersion": "^0.1.0",
"vendor": "Acme Corp",
"description": "Acme's analytics dashboard plugin",
"icon": "https://cdn.example.com/my-plugin/icon.svg",
"permissions": ["registry:nav", "registry:router", "network:external"],
"enabled": true
}Read shellApiVersion: "^0.1.0" as "I work with any 0.1.x shell; block me on 0.2". The host
compares it against the apiVersion its store advertises and, with the default
versionPolicy: "block", skips you on a mismatch rather than crashing mid-boot.
Lint your entry before you ship it:
import { validateManifestEntry } from "@z0devs/mf-core";
const result = validateManifestEntry(JSON.parse(raw));
if (!result.valid) throw new Error(result.errors.join("\n"));Every field, including dependencies and remoteEntryHash, is documented in the
manifest reference.
Depending on another plugin#
{
"name": "invoices",
"dependencies": { "billing": "^2.1.0" },
"optionalDependencies": { "analytics": "^3.0.0" }
}A missing or out-of-range required dependency blocks your plugin and emits dependency-unmet.
Optional ones are tolerated when absent, still checked when present.
Not for npm packages
dependencies here means other plugins. Shared libraries (React, a design system) are negotiated
by Module Federation's shared config, not by the manifest.
Lifecycle#
| Event | What runs |
|---|---|
| Shell startup | Host loads the manifest → mounts <InitRemote> per entry → your init renders → your effects register |
| User hits your route | The host's router renders the element you registered |
| Org / context switch | Host re-mounts the tree: your cleanup runs, then init re-registers |
| Plugin unmount | Your useEffect cleanups fire |
| Shell shutdown | Nothing library-side — close your own timers and sockets in cleanup |
There are no separate onAuthChange / onOrgChange callbacks. Subscribe to the relevant shell
store (values.useGlobalStore()) or to the host's event bus and react with normal React patterns.
Common pitfalls#
| Pitfall | Symptom | Fix |
|---|---|---|
No cleanup in init | Duplicate nav items, ghost routes after an org switch | Return a cleanup from useEffect |
Calling addNavItem | not a function | Registries are canonical: addItem / removeItem / getItem / getItems |
Runtime-importing shell/* types | Bundling the host into your remote | import type only; the bridge owns the runtime import |
| React not shared | “Invalid hook call” inside federated components | Use z0Shared() from @z0devs/mf-config — singleton: true, eager: true for react, react-dom, react-router-dom and the react/jsx-runtime, react/jsx-dev-runtime, react-dom/client subpaths. Sharing only the roots is the usual cause |
Calling getItem/getItems in render | Correct on first paint, silently stale after another plugin loads or a flag flips | useRegistryItem(registry, id) / useRegistryItems(registry) |
| Hardcoded API URLs | Works locally, wrong tenant in production | Use the host's contextualApiFetch |
| Unprefixed route ids where the host doesn't namespace them | Two plugins fight over one path | Prefix with your remote id (my_plugin:home) when registering routes outside the routes registry |