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

Deploying plugins

Production hosting for remotes — CORS, caching strategies, env-config, integrity, CSP, and a pre-launch smoke test.

The host doesn't care where your remoteEntry.js lives — only that the URL in the manifest is reachable from the user's browser, serves the right MIME type, and survives caching. Everything here is about making that boring.

Bare minimum#

Serve over HTTPS, per plugin:

  • <base-url>/remoteEntry.js — the Module Federation entry your bundler emits
  • <base-url>/<chunk>.<hash>.js — code-split chunks it references
  • <base-url>/env-config.js — optional, only if you need runtime config
  • *.map — if you want production debugging

With CORS:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, OPTIONS

* is fine for dev and public demos. In production, narrow it to the shell origins that load you.

Deploy targets#

TargetWhyWatch out for
Cloudflare Pages / Vercel / NetlifyFree tier, automatic HTTPS, header config, edge cacheBest default — push dist/
S3 + CloudFrontCheap at scale, full cache controlConfigure bucket CORS and per-type cache headers
GitHub PagesFree, trivialNo auth-gated access — public-vendor plugins only
Self-hosted nginxFull controlYou operate it
Container behind your gatewayReuses existing auth / WAFHeavy for static files

CORS in detail#

Plugins load cross-origin; without the right headers the browser blocks the script before your code ever runs.

location /plugins/my-plugin/ {
  add_header Access-Control-Allow-Origin "https://app.example.com" always;
  add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
  add_header Cross-Origin-Resource-Policy "cross-origin" always;
  add_header Vary "Origin" always;
 
  if ($request_method = OPTIONS) {
    return 204;
  }
 
  try_files $uri =404;
}

Add Access-Control-Allow-Credentials: true only if your env-config needs cookies, and Vary: Origin whenever headers differ per origin.

Caching#

Browser caching of remoteEntry.js is the number-one source of "stale plugin" bugs. Pick a strategy deliberately and write it down.

Cache-Control: public, max-age=31536000, immutable

Emit remoteEntry.<hash>.js and update the manifest URL each release. Aggressive caching is safe. Trade-off: a manifest update per release — usually one database row.

Pattern B — short TTL, stable filename#

Cache-Control: public, max-age=300, must-revalidate

Manifest URL never changes; propagation takes up to the TTL, and open tabs can run old code that long.

Pattern C — ETag revalidation#

Cache-Control: public, max-age=0, must-revalidate
ETag: "<sha256-of-content>"

One cheap round-trip per load. Good for low-traffic plugins.

Chunks: always Pattern A#

Cache-Control: public, max-age=31536000, immutable

Rspack content-hashes chunk names by default — verify your output looks like <name>.<hash>.js.

Env config#

For runtime-injected values (API URLs, flags, customer-specific settings), serve an env-config.js at your base URL:

public/env-config.js
window.__MFE_ENV__ = window.__MFE_ENV__ || {};
window.__MFE_ENV__["my_plugin"] = {
  apiBaseUrl: "https://api.example.com",
  featureFlags: { newDashboard: true },
};

BaseRemote loads it before mounting your init. The host can deep-merge shell-level config underneath it (via MFEnvProvider.shellConfigLoader); your values win on conflict. By default the URL is derived as ${origin}/env-config.js, overridable with config.buildEnvConfigUrl on the host.

Never cache env-config aggressively

It's the per-deploy config knob. Cache-Control: public, max-age=60, must-revalidate at most — otherwise users run new code against old config.

Integrity#

Publish a hash of the exact bytes you vetted and let the host verify before execution:

{
  "service": { "id": "my_plugin", "remoteEntry": "https://cdn/…/remoteEntry.js" },
  "remoteEntryHash": "sha256-…"
}

Mismatch → integrity-violated, no retry. Note that the MF runtime does not yet honour the <script integrity> attribute for registered remotes, which is why the check lives in the loader.

Manifest signing is out of scope for the library — sign server-side if your threat model needs it, and host the manifest on an access-controlled endpoint either way.

CSP#

Your shell's policy has to allow every plugin origin explicitly:

Content-Security-Policy: script-src 'self' https://cdn.acme.com https://plugins.partner.com;

The Module Federation runtime works without 'unsafe-eval' — don't add it unless a shared dependency forces you to.

Dev mode#

rspack serve / webpack-dev-server set Access-Control-Allow-Origin: * when configured (the basic-plugin example does), which is all local cross-port dev needs. Cross-boundary HMR between shell and plugin is not supported — reload each side independently.

Pre-launch checklist#

  • remoteEntry.js served with Content-Type: application/javascript (not text/plain)
  • CORS headers present on the OPTIONS preflight, not just GET
  • Chunks same-origin as remoteEntry.js, or CORS'd themselves
  • No mixed content — an HTTPS shell cannot load an HTTP plugin
  • Vary: Origin when headers differ per origin
  • env-config.js on a short TTL
  • Sourcemaps reachable, or stripped
  • Manifest version + shellApiVersion match what's actually deployed
  • Shell CSP script-src includes your origin

Smoke test#

# 1. CORS preflight
curl -i -H "Origin: https://app.example.com" \
     -H "Access-Control-Request-Method: GET" \
     -X OPTIONS https://cdn.example.com/plugins/my-plugin/remoteEntry.js
 
# 2. Reachable + correct MIME
curl -i https://cdn.example.com/plugins/my-plugin/remoteEntry.js | head -20
 
# 3. Cache headers sane
curl -I https://cdn.example.com/plugins/my-plugin/remoteEntry.js
// 4. Manifest entry validates against the library schema
import { validateManifestEntry } from "@z0devs/mf-core";
console.log(validateManifestEntry(entry));

All four green and any shell can load you.

Next#