Private beta#
The @z0devs/* packages are in private beta: they're served from Zone Zero's registry to design
partners while we harden for general availability, and installing them needs an access token —
that's the Z0_NPM_TOKEN in every snippet below. There are two ways in:
- Try the live demo — a real federated shell loading real plugins in your browser, including the failure gallery. No install, no token.
- Request beta access — design partners get white-glove onboarding, a direct channel, and a registry token. Four questions, we reply within a day.
Already a partner? Continue below.
Registry access#
Two registries, one rule: the scaffolder is public, the runtime packages are gated.
create-z0-pluginlives on npmjs (MIT-licensed), sopnpm create z0-pluginworks with no setup at all — no token, no.npmrc, any package manager. It also writes the.npmrcbelow into the project it generates.@z0devs/*live on ZoneZero's private registry and need an access token. Point the scope at it once, per project or per machine:
@z0devs:registry=https://npm.zonezero.dev/
//npm.zonezero.dev/:_authToken=${Z0_NPM_TOKEN}Set Z0_NPM_TOKEN in your shell (or CI secret store) before installing. It's deliberately not
named NPM_TOKEN — your CI may already define that for npmjs, and a collision sends a token to the
wrong registry. The scoped line covers every @z0devs/* package; nothing else about your registry
configuration needs to change — in particular, don't set registry= (the default) to the private
registry, or every unscoped package on earth will try to resolve through it.
pnpm may quarantine a brand-new release
pnpm 11's minimumReleaseAge policy treats any just-published version as suspect for its first
hours — including ours, straight off a release. If an install is refused (or loose mode warns about
"immature versions"), see
the troubleshooting entry
— the fix is a per-package exemption, not a weaker global policy.
Need a token?
Request beta access — tokens are issued per design partner, never shared.
Install#
pnpm add @z0devs/module-federation \
react react-dom react-router-dom eventemitter3 react-error-boundary \
@module-federation/runtime @tanstack/react-queryOne package re-exporting every sibling, plus subpath entries (/core, /router, /registry,
/context, /shell-kit, /plugin-kit) for tighter tree-shaking.
Or skip the wiring entirely and scaffold:
pnpm create z0-plugin my-pluginPeer dependencies#
Every package declares its peers explicitly rather than bundling them — the whole point is that the host and its plugins share one copy of each.
| Package | Peers |
|---|---|
@z0devs/mf-context | react >=18, eventemitter3 ^5 |
@z0devs/mf-router | react >=18, react-dom >=18, react-router-dom >=6.4 <8, @z0devs/mf-registry |
@z0devs/mf-registry | react >=18, eventemitter3 ^5 |
@z0devs/mf-core | react >=18, react-dom >=18, react-error-boundary ^4 || ^5, @module-federation/runtime >=0.6.0 <3.0.0 |
@z0devs/mf-shell-kit | the four primitives + react-router-dom >=6.4 <8, @tanstack/react-query >=5 |
@z0devs/mf-plugin-kit | react >=18, @z0devs/mf-registry >=0.2.0 |
@z0devs/mf-testing | the shell-side packages + @testing-library/react (optional) |
React 19 is permitted everywhere (>=18). React Router 6 and 7 both work — the packages import
through a single react-router-dom re-export seam, and that specifier resolves identically in both
majors.
Bundler requirements#
| Bundler | Status | Notes |
|---|---|---|
| Rspack | Verified | @module-federation/enhanced/rspack. Top-level await in exposes works with no flags. |
| Webpack 5 | Supported | Same engine; set experiments.topLevelAwait: true if you use the top-level-await store bridge. |
| Vite | Verified | @module-federation/vite remotes load under an Rspack host — see cross-bundler requirements. Serve the built output, not the dev server. |
The Module Federation runtime peer range spans 0.6 → 2.x; the library has been validated against
@module-federation/runtime and @module-federation/enhanced 2.5.1, with consumers on 0.21.x
still type-checking.
Required MF config on both sides#
Whatever bundler you use, the host and every plugin must share React as a singleton — two React copies in one tree is the single most common federation failure:
Don't write that map by hand — @z0devs/mf-config emits it, subpaths included:
import { z0Federation } from "@z0devs/mf-config";
new ModuleFederationPlugin(
z0Federation({
name: "shell", // or your plugin's remote id
exposes: { "./sharedStore": "./src/store/sharedStore.tsx" },
router: true, // also share react-router-dom
}),
);That expands to exactly this, which is what you'd otherwise maintain in every app:
shared: {
react: { singleton: true, eager: true, requiredVersion: false },
"react-dom": { singleton: true, eager: true, requiredVersion: false },
"react-router-dom": { singleton: true, eager: true, requiredVersion: false },
// Subpath entrypoints must be shared too — not just the package roots.
"react/jsx-runtime": { singleton: true, eager: true, requiredVersion: false },
"react/jsx-dev-runtime": { singleton: true, eager: true, requiredVersion: false },
"react-dom/client": { singleton: true, eager: true, requiredVersion: false },
}z0Federation returns plain options data, so nothing is a one-way door: spread it and override any
field, or import only z0Shared() and keep the rest of your block hand-written. Add it as a
devDependency — it is build-time only and never reaches a browser bundle.
Share sets must be symmetric
Code compiled with the automatic JSX runtime imports react/jsx-runtime directly. If that
subpath isn't in the share set, it silently resolves to the bundle's own copy — a second React, and
Cannot read properties of null (reading 'useEffect') on the first hook. Declaring it on the host
only makes things worse: the host then advertises a singleton its remotes don't declare, and the
duplicate simply moves to a different remote. Every participant declares the same set.
The host additionally exposes its store, and each plugin declares the host as a remote:
exposes: { "./sharedStore": "./src/store/sharedStore.tsx" }remotes: { shell: "shell@http://localhost:3400/remoteEntry.js" },
exposes: { "./init": "./src/init.tsx", "./HomePage": "./src/HomePage.tsx" }Don't bundle React into a remoteEntry
If react, react-dom or react-router-dom are missing from shared, the plugin ships its own
copy and every hook call inside a federated component throws “invalid hook call”. See
Troubleshooting.
Cross-bundler: a Vite remote under an Rspack host#
Supported and verified end to end. It needs four non-default settings on the Vite side and one you must avoid — enough detail that it has its own page: Cross-bundler.
The prerequisite is the section above: share the subpath entrypoints on every participant. An
unshared react/jsx-runtime sends the Vite remote to its own React, and that is the failure most
often mistaken for "Vite does not work here".
Standalone flag#
The plugin bridge decides between the real host store and the local stub by reading
process.env.IS_STANDALONE, which does not exist in a browser — inline it at build time:
new DefinePlugin({
"process.env.IS_STANDALONE": JSON.stringify(process.env["IS_STANDALONE"] ?? "false"),
});{
"scripts": {
"dev": "rspack serve",
"dev:standalone": "cross-env IS_STANDALONE=true rspack serve"
}
}Verify the install#
import { SHELL_API_VERSION, validateManifestEntry } from "@z0devs/mf-core";
console.log(SHELL_API_VERSION); // the host↔plugin contract version
console.log(validateManifestEntry(myManifestEntry)); // lint an entry before you ship it