@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#
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:
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.