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

Routing

How remotes register React Router routes at runtime, and how the host rebuilds its router when they do.

@z0devs/mf-router is a dynamic React Router host: routes can be added and removed while the app runs, which is exactly what a plugin needs to do when its bundle arrives mid-boot.

Routes are a registry — the same primitive as nav items and settings entries — so they inherit per-remote id namespacing, clearRemote cleanup and flags gating for free.

Host: mount the provider#

src/main.tsx
import { RouterContextProvider, Router } from "@z0devs/mf-router";
 
const initialRoutes = [
  {
    path: "/",
    element: <RootLayout />,
    children: [
      {
        element: <AppPluginRouteLoader />,   // where plugins get loaded
        children: [
          { index: true, element: <Home /> },
          // dynamic remote routes are injected here
        ],
      },
    ],
  },
];
 
export default function App() {
  return (
    <RouterContextProvider initialRoutes={initialRoutes} defaultErrorElement={<ErrorPage />}>
      <Router />
    </RouterContextProvider>
  );
}

RouterContextProvider reads the route registry, injects registered routes at the dynamic target, and rebuilds the router whenever the set changes. <Router /> renders the current instance; useRouterContext() returns { router } if you need it directly.

The injection point is fixed by convention

The provider injects into initialRoutes[0].children[0].children — the children of a layout route nested inside your top-level layout. Shape your tree that way, or fork the provider if you need a different point.

defaultErrorElement is applied to every dynamically-registered route that doesn't bring its own, so a plugin page crashing renders your error UI instead of unmounting the shell.

Plugin: register routes#

With a shell built on @z0devs/mf-shell-kit and features: { router: true }, routes arrive as a namespaced registry in your init props — no import, ids auto-prefixed:

src/init.tsx
export default function Init({ registries }) {
  useEffect(() => {
    registries.routes.addItem({ id: "home", path: "/my-plugin", element: <HomePage /> });
    registries.routes.addItem({
      id: "detail",
      path: "/my-plugin/:itemId",
      element: <DetailPage />,
      errorElement: <MyPluginError />,     // any RouteObject field works
    });
 
    return () => {
      registries.routes.removeItem("home");
      registries.routes.removeItem("detail");
    };
  }, [registries]);
 
  return null;
}

A RouteItem is a react-router RouteObject plus the registry fields — so loader, action, children, errorElement and lazy all behave as they normally do, alongside id, order and flags.

Without shell-kit (or in shell-local code), use the registry singleton:

import { routeRegistryApi } from "@z0devs/mf-router";
 
useEffect(() => {
  routeRegistryApi.addItem({ id: "mfe-home", path: "/home", element: <Home /> });
  return () => routeRegistryApi.removeItem("mfe-home");
}, []);

Namespacing

Through init props, ids are prefixed for you (my_plugin:home). Through routeRegistryApi directly they are not — prefix them yourself so two remotes can't collide.

Feature-gated routes#

registries.routes.addItem({
  id: "beta-dashboard",
  path: "/dashboard-beta",
  element: <BetaDashboard />,
  flags: { required: ["beta"] },
});

The host controls the active set with setActiveFlags, and the router only sees routes that pass. Flipping a flag rebuilds the router — no reload.

Nested and layout routes#

A plugin can own a whole subtree:

registries.routes.addItem({
  id: "billing",
  path: "/billing",
  element: <BillingLayout />,        // renders its own <Outlet />
  children: [
    { index: true, element: <InvoiceList /> },
    { path: ":invoiceId", element: <InvoiceDetail /> },
    { path: "settings", element: <BillingSettings /> },
  ],
});

One registry item, one cleanup call, an arbitrarily deep tree.

React Router 6 and 7#

The package imports react-router through a single re-export module and declares react-router-dom: >=6.4 <8, so the same build works on both majors. Both host and plugins must share the instance:

// z0Shared({ router: true }) / z0Federation({ router: true }) emit this alongside the React set:
shared: {
  "react-router-dom": { singleton: true, eager: true, requiredVersion: false },
}

That is what router: true means in @z0devs/mf-config — it is off by default because eager: true on a package a remote does not depend on fails the build, so a remote that only contributes a nav item should not declare it.

Two router copies means a plugin registering into a router the host isn't rendering — links change the URL and nothing else happens.

Prefetching a route's bundle#

Warm a plugin's code before the user navigates:

import { usePluginPrefetch } from "@z0devs/mf-core";
 
function PluginNavLink({ entry }: { entry: MFEManifestEntry }) {
  const { hoverProps, prefetchOnIdle } = usePluginPrefetch(entry);
  useEffect(prefetchOnIdle, [prefetchOnIdle]);      // cheap warm when the browser is idle
  return <NavLink {...hoverProps} to={entry.path}>{entry.name}</NavLink>;  // warm on hover
}

Prefetch is idempotent and swallows its own errors — a failed warm never breaks the real load.

Next#