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

Contributions & registries

How plugins add nav items, settings panels, toolbar slots and shared components — with per-remote namespacing, capability scoping and flag gating.

A registry is a shared, ordered, event-emitting list that plugins append to and the host renders from. @z0devs/mf-registry ships five built-ins — nav, settings, toolbar, components, onboardingSteps — plus a factory for your own. A sixth, routes, is injected by defineSharedStore when you set features.router, so plugins normally see six.

RegistryItem typeRendered by
navNavItem{ id, title?, url?, icon?, onClick?, component? }Your sidebar / top nav
settingsSettingsItem{ id, icon, title, subtitle?, scope?, color?, url?, onClick?, element? }Your settings page
toolbarToolbarItem{ id, icon, element, visibleSignal?, slotProps? }A header slot
componentsDynamicComponent{ id, component }Any plugin that looks it up — cross-plugin UI
routesRouteItem — a react-router RouteObject + idRouterContextProvider (routing)
onboardingStepsOnboardingStepOnboardingHost (onboarding)

Every item also accepts the shared RegistryItem fields: id (required), order and flags.

Host: wire them into the store#

import {
  NavRegistry,
  SettingsRegistry,
  ToolbarRegistry,
  ComponentRegistry,
} from "@z0devs/mf-registry";
import { defineSharedStore } from "@z0devs/mf-shell-kit";
 
export const sharedStore = defineSharedStore({
  values: { useAuth },
  registries: {
    nav: NavRegistry.api,               // built-ins export { Context, api, useRegistry }
    settings: SettingsRegistry.api,
    toolbar: ToolbarRegistry.api,
    components: ComponentRegistry.api,
  },
  features: { router: true, onboarding: true },   // injects `routes` + `onboardingSteps`
});

Then render reactively:

import { NavRegistry, useRegistryItems } from "@z0devs/mf-registry";
 
function Sidebar() {
  // Reactive: re-renders on add, remove, and on a flag change that alters which items are listed.
  // `NavRegistry.useRegistry().getItems()` would NOT — the api object is stable forever, so that read is
  // correct on first paint and silently stale afterwards.
  const items = useRegistryItems(NavRegistry.api);
  return (
    <nav>
      {items.map((item) => (
        <NavLink key={item.id} to={item.url}>
          {item.title}
        </NavLink>
      ))}
    </nav>
  );
}

Items sort by order (a string, compared lexicographically) unless you pass a custom sort to createRegistryContext. Strings make it possible to slot between two existing items without renumbering — "m" sits between "a" and "z".

Rendering the other registries#

Nothing renders itself. Every registry a plugin can write to needs a host component reading it, and the shape differs per registry — this is host work you have to do once:

// This is HOST code, so the hooks come from @z0devs/mf-registry. (A plugin gets the same two
// re-exported from @z0devs/mf-plugin-kit, the package it already depends on.)
import {
  SettingsRegistry,
  ToolbarRegistry,
  ComponentRegistry,
  useRegistryItem,
  useRegistryItems,
} from "@z0devs/mf-registry";
 
// Settings — each item carries its own panel element.
function SettingsPage() {
  const items = useRegistryItems(SettingsRegistry.api);
  return (
    <Tabs>
      {items.map(({ id, title, element }) => (
        <Tab key={id} title={title}>{element}</Tab>
      ))}
    </Tabs>
  );
}
 
// Toolbar — same pattern, usually rendered into a header slot.
function Toolbar() {
  const items = useRegistryItems(ToolbarRegistry.api);
  return <div className="toolbar">{items.map(({ id, element }) => <span key={id}>{element}</span>)}</div>;
}
 
// Components — resolved by id on demand, not mapped over. This is how one plugin renders
// another's widget without importing it.
function Slot({ id }: { id: string }) {
  // Subscribes to THIS item, so it appears and disappears as the owning plugin is toggled, and an
  // unrelated plugin's registrations re-render nothing.
  const entry = useRegistryItem(ComponentRegistry.api, id); // e.g. "basic_plugin:SalesWidget"
  if (!entry) return null;                                  // plugin absent or disabled
  const Component = entry.component;
  return <Component />;
}

routes is the exception: you never map over it. <Router> from @z0devs/mf-router reads the route registry itself — see Routing. Same for onboardingSteps, which PluginRouteLoader evaluates through <OnboardingHost>.

Remember the ids are namespaced. A Slot looking for a specific plugin's widget asks for "<remote_id>:<local_id>", and returning null when it is missing is the correct behaviour — the plugin may be disabled, version-blocked or simply not in this deployment's manifest.

Plugin: append and clean up#

Excerpt — the InitProps type and imports are in Build a plugin:

src/init.tsx
export default function Init({ registries }: InitProps) {
  useEffect(() => {
    registries?.nav?.addItem({ id: "home", title: "My Plugin", url: "/my-plugin", order: "m" });
    registries?.settings?.addItem({ id: "prefs", title: "My Plugin", element: <Prefs /> });
    registries?.components?.addItem({ id: "InvoiceWidget", component: InvoiceWidget });
 
    return () => {
      registries?.nav?.removeItem("home");
      registries?.settings?.removeItem("prefs");
      registries?.components?.removeItem("InvoiceWidget");
    };
  }, [registries]);
 
  return null;
}

Ids arrive on the host prefixed with your remote name: my_plugin:home. You pass short local ids; the host-side withRemoteNamespace wrapper does the prefixing, so two plugins can both ship id: "home" without knowing about each other.

Another plugin reads your published component by its full id:

import { useRegistryItem } from "@z0devs/mf-plugin-kit";
 
const widget = useRegistryItem(registries.components, "my_plugin:InvoiceWidget");
return widget ? <widget.component invoiceId={id} /> : <Fallback />;

Capability scoping#

Hosts and remotes do not get the same API. The host keeps RegistryApi; a remote only ever receives PluginRegistryApi:

CapabilityHost (RegistryApi)Remote (PluginRegistryApi)
addItem / removeItemyesyes — ids scoped to the remote
getItem / getItems / getActiveFlagsyesyes
subscribeyesyes — a read capability, unlike the emitter
setActiveFlagsyesno — the flag set is global, host-only
emit / addListener / removeListeneryesno — shared bus, host-only
clearRemoteyesno — wipes a remote's items, host-only

Being told the listed set changed is a read capability, so a remote gets subscribe and can re-render itself — that is what useRegistryItem / useRegistryItems are built on. What it must not have is emit (a broadcast, so it could fake another plugin's events) or addListener (it could watch another remote's typed traffic). Conflating the two is what previously forced every host to hand plugins a revision counter as a value.

The two ways to build the remote-facing view, if you're not using defineSharedStore:

import { withRemoteNamespace, toPluginRegistry, NavRegistry } from "@z0devs/mf-registry";
 
// With identity: add/remove auto-prefixed with "ai_mfe:".
const initNav = withRemoteNamespace(NavRegistry.api, "ai_mfe");
initNav.addItem({ id: "dashboard" });        // stored as "ai_mfe:dashboard"
 
// Without identity: same narrowed surface, no prefixing — for read-mostly component access.
const readNav = toPluginRegistry(NavRegistry.api);

defineSharedStore applies both for you: useInitProps(remoteName) returns the namespaced view, useShellSharedStore() the read view.

Scoping is not a security boundary

It prevents accidental cross-remote interference and gives each remote a minimal typed surface. A remote sharing the JS realm can still reach the raw registry through globalThis or a direct @z0devs/mf-registry import. Real isolation needs an out-of-realm boundary — see Trust model.

Feature-flag gating#

Registry items may declare flags; the host sets the active set once and reads filter themselves:

import { NavRegistry } from "@z0devs/mf-registry";
 
NavRegistry.api.setActiveFlags(["beta", "new-billing"]);   // re-emits FLAGS_CHANGED
registries.nav.addItem({
  id: "beta-dashboard",
  title: "Dashboard (beta)",
  url: "/dashboard-beta",
  flags: { required: ["beta"], disallowed: ["legacy-ui"] },
});
  • No flags → always visible.
  • required → every listed flag must be active.
  • disallowed → hidden if any listed flag is active.
  • Gating is uniform: getItem(id) applies the same filter as getItems(), so a hidden item reads as absent through every read path. There is no unfiltered read — one would defeat the gate.

Cleaning up after a remote#

When a plugin is unloaded (or failed and you want its contributions gone), the host can wipe everything it registered:

NavRegistry.api.clearRemote("ai_mfe");   // removes every "ai_mfe:*" item, emits ITEM_REMOVED

Your own registry#

import { createRegistryContext, useRegistryItems } from "@z0devs/mf-registry";
 
type Widget = { id: string; order?: string; render: () => JSX.Element };
 
export const WidgetRegistry = createRegistryContext<Widget>({
  contextKey: "WidgetRegistry",     // globalThis key — keep it unique per registry
});
 
// anywhere
WidgetRegistry.api.addItem({ id: "weather", render: () => <Weather /> });
 
// in a component — reactive, so it re-renders when items are added, removed, or flag-gated in/out
function WidgetBar() {
  const widgets = useRegistryItems(WidgetRegistry.api);
  return <div>{widgets.map((w) => <span key={w.id}>{w.render()}</span>)}</div>;
}

Add it to defineSharedStore({ registries: { widgets: WidgetRegistry.api } }) and plugins receive it namespaced, flag-gated and capability-scoped with no extra work.

Registries survive bundles

Every registry is backed by a globalThis singleton, so the host and all remotes see the same instance even though they're separate builds.

Next#