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

Standalone development

Run a plugin with no host shell — fail-loud stubs, the standalone flag, and keeping the stub honest.

If a plugin can only run inside the host, every plugin author has to run your entire platform locally: shell, backend, database, auth. Onboarding takes a week and "works on my machine" becomes structural.

Standalone mode removes that. The plugin boots against a stub of the host store, in its own dev server, in seconds.

IS_STANDALONE=true pnpm dev      # the scaffold wires this as `pnpm dev:standalone`

How the switch works#

createPluginStoreBridge decides between the federated store and the local fallback:

src/core/useShared.tsx
import { createPluginStoreBridge } from "@z0devs/mf-plugin-kit";
import LocalContext from "./LocalContext";
 
const bridge = createPluginStoreBridge({
  loadShellModule: () => import("shell/sharedStore"),
  LocalContextFallback: LocalContext,
});
 
await bridge.ready;
export const { useShellSharedStore } = bridge;
  • Federated (IS_STANDALONE !== "true"): imports shell/sharedStore and calls its default export as a hook.
  • Standalone: resolves to useContext(LocalContextFallback).
  • Federated import failed: logs and falls back to the stub, so a shell that's down doesn't stop your dev loop.

Because process doesn't exist in a browser, the flag has to be inlined at build time:

rspack.config.ts
new DefinePlugin({
  "process.env.IS_STANDALONE": JSON.stringify(process.env["IS_STANDALONE"] ?? "false"),
});

Why DefinePlugin and not a typeof guard

The bridge reads process.env.IS_STANDALONE by direct dot access inside a try/catch. A typeof process guard looks safer but is worse: DefinePlugin substitutes the env expression and leaves typeof process untouched, which silently defeats a correctly-inlined flag.

Need different semantics? Pass your own detector:

createPluginStoreBridge({
  loadShellModule: () => import("shell/sharedStore"),
  LocalContextFallback: LocalContext,
  isStandalone: () => window.location.search.includes("standalone=1"),
});

Fail-loud stubs#

The failure you want to avoid is a stub that quietly returns undefined for something the real host provides — the plugin renders fine locally and breaks in production. createLocalStubContext makes that impossible: it's a Proxy that throws on any slot you didn't stub.

src/core/LocalContext.ts
import { createLocalStubContext } from "@z0devs/mf-plugin-kit";
 
type LocalShellShape = {
  values: {
    useAuth: () => { user: { id: string; email: string } | null; isLoading: boolean };
    useCurrentUser: () => { id: string; name: string };
  };
  registries: Record<string, never>;
};
 
const LocalContext = createLocalStubContext<LocalShellShape>({
  values: {
    useAuth: () => ({ user: { id: "local-user", email: "dev@localhost" }, isLoading: false }),
    useCurrentUser: () => ({ id: "local-user", name: "Local Dev" }),
  },
});
 
export default LocalContext;

Read something unstubbed and you get, immediately and by name:

[@z0devs/mf-plugin-kit] missing local stub for "contextualApiFetch"

Keeping the stub honest#

Two layers, and you want both:

Type the slice against the real store

Replace the hand-written LocalShellShape with the host's actual type as soon as the shell publishes one:

import type useShellSharedStore from "shell/sharedStore";
import type { SelectSharedStoreSlice } from "@z0devs/mf-plugin-kit";
 
type Slice = SelectSharedStoreSlice<
  ReturnType<typeof useShellSharedStore>,
  { values: { useAuth: true; contextualApiFetch: true }; registries: { components: true } }
>;

Now the stub has to satisfy Slice. If the host changes useAuth's return type, your build fails — which is where you want to find out.

Wire the host's published types

The host's bundler emits dist/@mf-types (dts.generateTypes). Point your tsconfig at it:

tsconfig.json
{
  "compilerOptions": {
    "paths": { "shell/*": ["../shell/dist/@mf-types/shell/*"] }
  }
}

Or consume the whole emitted bundle with "paths": { "*": ["./@mf-types/*"] }.

Standalone entry point#

The scaffold ships a normal React entry that runs only in standalone mode — under the host, this file is never loaded because the host imports ./init and your pages directly.

src/main.tsx
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
 
function StandaloneApp() {
  return (
    <BrowserRouter>
      <nav style={{ padding: 12, borderBottom: "1px solid #ccc" }}>
        <Link to="/">Home</Link>
      </nav>
      <Routes>
        <Route path="/" element={<HomePage />} />
      </Routes>
    </BrowserRouter>
  );
}
 
createRoot(document.getElementById("root")!).render(<StandaloneApp />);

Because the bridge module top-level-awaits bridge.ready, the store is bound before this file evaluates — no provider wrapper needed even here.

What standalone does not cover#

Not coveredWhyWhat to do instead
Real registriesThe host owns them; your stub's are inertAssert registrations with @z0devs/mf-testing
Route registrationThere's no host router to register intoRender pages directly in main.tsx
Onboarding gatesOnboardingHost lives in the hostTest predicates as pure functions
Cross-plugin componentsAnother remote publishes themStub getItem to return your own fixture
Real auth / tenant contextStub values are fixturesSmoke-test against a dev shell before release

A realistic dev loop

Standalone for the inner loop (UI, state, styling), @z0devs/mf-testing for assertions in CI, and one federated smoke run against a dev shell before you ship a manifest bump. Cross-boundary HMR between shell and plugin is not supported — live-reload each side independently.

Next#