Almost every platform has a few "you can't use the app until…" conditions: accept the EULA, finish profile setup, pick an organisation, complete a tutorial. In a plugin architecture they get worse, because plugins want to add their own — and each one thinks it should come first.
The onboarding registry models that: plugins register steps, the host evaluates the highest-priority unmet one, and the library renders it.
Enable it#
export const sharedStore = defineSharedStore({
values: { useAuth },
registries: { nav: NavRegistry.api },
features: { onboarding: true }, // injects `onboardingSteps` + auto-mounts <OnboardingHost/>
});With that key present, PluginRouteLoader mounts an <OnboardingHost> above its <Outlet/>
automatically. To opt out, pass config={{ onboardingRegistryKey: null }}.
The three actions#
type OnboardingStep =
| { id: string; priority?: number; predicate: (ctx: { pathname: string }) => boolean;
action: "redirect"; path: string }
| { id: string; priority?: number; predicate: (ctx: { pathname: string }) => boolean;
action: "render"; render: () => ReactNode }
| { id: string; priority?: number; predicate: (ctx: { pathname: string }) => boolean;
action: "block"; reason: string };| Action | Effect | Use it for |
|---|---|---|
redirect | Navigates to path once per (id, path) pair | "Finish setup" wizards that own a route |
render | Renders your node over the app until the predicate flips | EULA modals, tutorials, org pickers |
block | Stops rendering entirely with a reason | Suspended accounts, missing entitlements |
Steps sort by priority (higher first), then order, then id. The host walks the sorted list and
acts on the first step whose predicate returns true.
Registering a step#
Excerpt — same src/init.tsx as
Build a plugin, with
onboardingSteps added to the InitProps registries and values.useAuth declared:
export default function Init({ values, registries }: InitProps) {
// Read reactive state HERE — the init component is a normal React component.
const { user } = values!.useAuth();
const eulaAccepted = user?.eulaAcceptedAt != null;
useEffect(() => {
registries?.onboardingSteps?.addItem({
id: "eula-acceptance",
priority: 100,
// Pure, synchronous, hook-free — it only reads the value captured above.
predicate: () => !eulaAccepted,
action: "render",
render: () => <EulaDialog />,
});
return () => registries?.onboardingSteps?.removeItem("eula-acceptance");
}, [registries, eulaAccepted]);
return null;
}No hooks inside a predicate
OnboardingHost evaluates predicates with a short-circuiting find(...) during its own render. A
hook in a predicate would run conditionally — only until the first match — and violate the rules of
hooks, crashing the host. Read auth/store/flag state where the step is registered, capture the values
you need, and branch on those.
Host-local steps#
The host registers steps the same way — no plugin required:
sharedStore.registries.onboardingSteps.addItem({
id: "shell:pick-org",
priority: 200, // ahead of any plugin step
predicate: ({ pathname }) => !currentOrgId && pathname !== "/select-org",
action: "redirect",
path: "/select-org",
});Note the pathname guard: without it, a redirect step whose predicate is still true on the target
route would fight the router. OnboardingHost calls its redirect callback once per (id, path)
pair, but a predicate that can never be satisfied is still a bug you want to avoid.
Ordering across plugins#
Priority is a plain number, and plugins don't coordinate — so pick bands and document them for your plugin authors:
| Band | Meaning |
|---|---|
1000+ | Account state the platform owns (suspended, billing failure) |
500–999 | Legal / compliance (EULA, DPA acceptance) |
100–499 | Setup that blocks meaningful use (pick an org, complete profile) |
1–99 | Nice-to-have tours and tips |
Testing a step#
import { createMockShellStore, renderWithMockShell } from "@z0devs/mf-testing";
const shell = createMockShellStore();
await renderWithMockShell(<MyPluginInit />, { shell });
const steps = shell.registries.onboardingSteps.getItems();
expect(steps.some((step) => step.id.endsWith(":eula-acceptance"))).toBe(true);
expect(steps[0].predicate({ pathname: "/" })).toBe(true);