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

Installation

Registry access, which packages to install for host and plugin, peer dependencies, and bundler requirements.

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-plugin lives on npmjs (MIT-licensed), so pnpm create z0-plugin works with no setup at all — no token, no .npmrc, any package manager. It also writes the .npmrc below 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:
.npmrc
@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-query

One 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-plugin

Peer 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.

PackagePeers
@z0devs/mf-contextreact >=18, eventemitter3 ^5
@z0devs/mf-routerreact >=18, react-dom >=18, react-router-dom >=6.4 <8, @z0devs/mf-registry
@z0devs/mf-registryreact >=18, eventemitter3 ^5
@z0devs/mf-corereact >=18, react-dom >=18, react-error-boundary ^4 || ^5, @module-federation/runtime >=0.6.0 <3.0.0
@z0devs/mf-shell-kitthe four primitives + react-router-dom >=6.4 <8, @tanstack/react-query >=5
@z0devs/mf-plugin-kitreact >=18, @z0devs/mf-registry >=0.2.0
@z0devs/mf-testingthe 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#

BundlerStatusNotes
RspackVerified@module-federation/enhanced/rspack. Top-level await in exposes works with no flags.
Webpack 5SupportedSame engine; set experiments.topLevelAwait: true if you use the top-level-await store bridge.
ViteVerified@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:

rspack.config.ts (host and plugin)
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:

host
exposes: { "./sharedStore": "./src/store/sharedStore.tsx" }
plugin
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:

rspack.config.ts (plugin)
new DefinePlugin({
  "process.env.IS_STANDALONE": JSON.stringify(process.env["IS_STANDALONE"] ?? "false"),
});
package.json (plugin)
{
  "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

Next#