Runtime composition with Webpack Module Federation, the sharp edges I hit at Superpeer and Kajabi, and when a monorepo beats it.
The first time I really felt the weight of a frontend org was at the creator economy platform I spent the last few years at. Big codebase, a lot of squads, and on a given week I’d usually be floating across two or three of them. Not every surface was a separately deployable app, but the spirit was there. Independent ownership, independent deploys, different release cadences, different review cultures. Honestly, that experience is what makes me cautious about Module Federation, not the other way around. Most teams reach for it about two years too early.
Here’s my take. Module Federation earns its cost when you genuinely have multiple teams shipping on different cadences, with their own product roadmaps, and the shell is mostly a layout shell that doesn’t change much. Anything short of that, you’re paying runtime complexity to solve a coordination problem you could’ve solved with CODEOWNERS, protected paths in CI, and feature flags.
You get one host (the shell) that loads JS bundles from other webpack builds at runtime. No npm publish, no rebuild of the shell when a remote changes. The remote ships a remoteEntry.js, the shell points at it, and React stays a singleton across both. That’s it. Everything else is config.
Here’s the production-shaped remote config I’d actually ship. Notice publicPath: "auto" instead of a hardcoded URL, and notice that I’m being careful about which packages get singleton.
// webpack.config.js for the "checkout" remote
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
output: {
publicPath: "auto",
uniqueName: "checkout",
},
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutFlow": "./src/CheckoutFlow",
"./useCart": "./src/hooks/useCart",
"./routes": "./src/routes",
},
shared: {
react: { singleton: true, requiredVersion: deps.react, eager: false },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
"react-router-dom": { singleton: true, requiredVersion: deps["react-router-dom"] },
"@platform/design-tokens": { singleton: true, requiredVersion: deps["@platform/design-tokens"] },
},
}),
],
};
The shell side mirrors this and adds the remote map. Don’t hardcode remote URLs into the shell’s webpack config. Move them to a manifest the shell fetches at boot, so a remote can roll forward without the shell redeploying.
// webpack.config.js for the shell
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
// Promise-based remotes let us inject the URL from a manifest at runtime.
checkout: `promise import('./remoteLoader').then(m => m.load('checkout'))`,
catalog: `promise import('./remoteLoader').then(m => m.load('catalog'))`,
},
shared: {
react: { singleton: true, requiredVersion: deps.react, strictVersion: false },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
"react-router-dom": { singleton: true, requiredVersion: deps["react-router-dom"] },
},
}),
],
};
The matching loader is honestly the ugliest piece of the puzzle. You’re poking at globals webpack sets up for each remote scope. It works, but treat it like infra code, not feature code.
// remoteLoader.ts
type Container = {
init: (scope: unknown) => Promise<void>;
get: (module: string) => Promise<() => unknown>;
};
const loaded = new Map<string, Container>();
export async function load(name: string): Promise<Container> {
if (loaded.has(name)) return loaded.get(name)!;
const manifest = await fetch("/runtime/remotes.json", { cache: "no-cache" })
.then((r) => r.json());
const url = manifest[name];
if (!url) throw new Error(`Unknown remote: ${name}`);
await new Promise<void>((resolve, reject) => {
const el = document.createElement("script");
el.src = url;
el.async = true;
el.onload = () => resolve();
el.onerror = () => reject(new Error(`Failed to load remote ${name} from ${url}`));
document.head.appendChild(el);
});
const container = (window as unknown as Record<string, Container>)[name];
// @ts-expect-error webpack's share scope is a runtime global
await container.init(__webpack_share_scopes__.default);
loaded.set(name, container);
return container;
}
Anything you put in shared.singleton is your shared blast radius. A design tokens package or a CSS reset shipped as a singleton means a bad version in one remote contaminates every other remote on the page. Treat singletons like you’d treat a database migration. Boundary them, version them, and don’t roll forward without a visual-regression gate.
Cache keys are part of your public API. If you’re composing a frontend at the edge, or even just caching remoteEntry.js aggressively at the CDN, treat any change to keying or to the entry filename like a schema migration. Don’t aggressively cache remoteEntry.js itself, only the content-hashed chunks it points to.
The deploy story is most of the actual value you’ll feel day to day. One workflow per remote, scoped by path filter, with the cache invalidation deliberately narrow.
name: deploy-checkout-remote
on:
push:
branches: [main]
paths: ["apps/checkout/**", ".github/workflows/deploy-checkout-remote.yml"]
jobs:
ship:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "pnpm" }
- run: pnpm --filter checkout install --frozen-lockfile
- run: pnpm --filter checkout test:ci
- run: pnpm --filter checkout build
- name: publish
run: |
aws s3 sync apps/checkout/dist/ s3://$CDN_BUCKET/checkout/ \
--cache-control "public, max-age=31536000, immutable" \
--exclude "remoteEntry.js"
aws s3 cp apps/checkout/dist/remoteEntry.js s3://$CDN_BUCKET/checkout/remoteEntry.js \
--cache-control "public, max-age=30, must-revalidate"
aws cloudfront create-invalidation \
--distribution-id $CF_DIST_ID --paths "/checkout/remoteEntry.js"
Two details worth stealing. Content-hashed chunks are immutable, remoteEntry.js is short TTL. And the invalidation only touches remoteEntry.js, because that’s the manifest, not the code.
If you’ve got fewer than three frontend teams, or your build times aren’t actually painful, you’re paying for a problem you don’t have. The shape I described earlier, lots of repos and rotating squads, is what justifies this kind of architecture. A single Next.js app with CODEOWNERS, protected paths in CI, and a feature-flag service will give you most of the autonomy benefits at a fraction of the runtime cost. I’ve watched teams adopt Module Federation off a conference talk and then spend a quarter chasing shared dependency version drift. That’s a sad way to spend a quarter.
remoteEntry.js, do cache the content-hashed chunks.Thanks for reading. If you’ve got thoughts, send them my way.