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

Build a plugin

The plugin author's contract — what the host gives you, what you give back, and how to ship it.

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 required

Then, 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#

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

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

src/core/useShared.tsx
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;
src/HomePage.tsx
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#

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

EventWhat runs
Shell startupHost loads the manifest → mounts <InitRemote> per entry → your init renders → your effects register
User hits your routeThe host's router renders the element you registered
Org / context switchHost re-mounts the tree: your cleanup runs, then init re-registers
Plugin unmountYour useEffect cleanups fire
Shell shutdownNothing 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#

PitfallSymptomFix
No cleanup in initDuplicate nav items, ghost routes after an org switchReturn a cleanup from useEffect
Calling addNavItemnot a functionRegistries are canonical: addItem / removeItem / getItem / getItems
Runtime-importing shell/* typesBundling the host into your remoteimport type only; the bridge owns the runtime import
React not shared“Invalid hook call” inside federated componentsUse z0Shared() from @z0devs/mf-configsingleton: 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 renderCorrect on first paint, silently stale after another plugin loads or a flag flipsuseRegistryItem(registry, id) / useRegistryItems(registry)
Hardcoded API URLsWorks locally, wrong tenant in productionUse the host's contextualApiFetch
Unprefixed route ids where the host doesn't namespace themTwo plugins fight over one pathPrefix with your remote id (my_plugin:home) when registering routes outside the routes registry

Next#