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#
| Target | Why | Watch out for |
|---|---|---|
| Cloudflare Pages / Vercel / Netlify | Free tier, automatic HTTPS, header config, edge cache | Best default — push dist/ |
| S3 + CloudFront | Cheap at scale, full cache control | Configure bucket CORS and per-type cache headers |
| GitHub Pages | Free, trivial | No auth-gated access — public-vendor plugins only |
| Self-hosted nginx | Full control | You operate it |
| Container behind your gateway | Reuses existing auth / WAF | Heavy 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.
Pattern A — content-hashed filename (recommended)#
Cache-Control: public, max-age=31536000, immutableEmit 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-revalidateManifest 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, immutableRspack 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:
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.jsserved withContent-Type: application/javascript(nottext/plain) - CORS headers present on the
OPTIONSpreflight, not justGET - Chunks same-origin as
remoteEntry.js, or CORS'd themselves - No mixed content — an HTTPS shell cannot load an HTTP plugin
-
Vary: Originwhen headers differ per origin -
env-config.json a short TTL - Sourcemaps reachable, or stripped
- Manifest
version+shellApiVersionmatch what's actually deployed - Shell CSP
script-srcincludes 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.