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

Onboarding steps

Priority-ranked gates that redirect, overlay or block the app until a condition is met — contributed by plugins, evaluated by the host.

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 };
ActionEffectUse it for
redirectNavigates to path once per (id, path) pair"Finish setup" wizards that own a route
renderRenders your node over the app until the predicate flipsEULA modals, tutorials, org pickers
blockStops rendering entirely with a reasonSuspended 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:

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

BandMeaning
1000+Account state the platform owns (suspended, billing failure)
500–999Legal / compliance (EULA, DPA acceptance)
100–499Setup that blocks meaningful use (pick an org, complete profile)
1–99Nice-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);

Next#