Configuring Node Federation for SSR Remotes #
A federated remote that renders in the browser cannot be required by a Node process — this page adds the second build target, publishes it beside the browser build, and loads it from a Node host.
The work splits cleanly: a webpack configuration change in each remote, a publishing change so both targets ship together, and a loader in the host that evaluates a module instead of injecting a script. It implements the setup described in server-side rendering federated modules.
Prerequisites #
- Webpack 5.90+ and Node 20+ on both host and remote.
- An existing browser federation setup, as in step-by-step Webpack 5 container configuration.
- React 18+ with
react-dom/server, or an equivalent server renderer. - A manifest the host reads at runtime, carrying both entry URLs per remote.
Step 1 — Add a server target to the remote’s build #
The server build shares source with the browser build and differs in target, library type and entry.
// remote/webpack.server.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('node:path');
const deps = require('./package.json').dependencies;
module.exports = {
mode: 'production',
// async-node emits code that loads chunks with fs/http rather than <script>.
target: 'async-node',
entry: './src/index.server.js',
output: {
path: path.resolve(__dirname, 'dist/server'),
filename: '[name].js',
chunkFilename: '[name].[contenthash].js',
library: { type: 'commonjs-module' },
publicPath: 'https://cdn.example.com/cart/server/',
clean: true,
},
plugins: [
new ModuleFederationPlugin({
name: 'cart',
filename: 'remoteEntry.js',
// Identical keys to the browser build — this is the contract.
exposes: { './Widget': './src/Widget' },
shared: {
react: { singleton: true, requiredVersion: deps.react, strictVersion: true },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'], strictVersion: true },
},
library: { type: 'commonjs-module' },
}),
],
};
target: 'async-node' is what changes chunk loading from script injection to a Node-native fetch. library: { type: 'commonjs-module' } makes the container something a Node process can consume. strictVersion: true matters more here than in the browser: a second React copy in one process means two renderers and incoherent HTML, so it should fail loudly.
Build both targets in one command so they cannot drift:
{
"scripts": {
"build": "npm run build:browser && npm run build:server",
"build:browser": "webpack --config webpack.browser.js",
"build:server": "webpack --config webpack.server.js"
}
}
Step 2 — Keep externals honest #
Node has built-in modules and a real node_modules. Bundling them into the server container wastes work and can produce two copies of a package the host already has.
// remote/webpack.server.js — leave Node builtins and true peers external
const nodeExternals = require('webpack-node-externals');
module.exports = {
// …
externalsPresets: { node: true },
externals: [
// Do NOT externalise shared federated deps — the share scope owns those.
nodeExternals({ allowlist: [/^react($|\/)/, /^react-dom($|\/)/] }),
],
};
The allowlist is the subtle part. Anything listed in shared must stay inside the bundle so the federation runtime can register it with the share scope. Externalising React here would bypass the negotiation entirely and let the host and remote resolve different copies.
Step 3 — Publish both targets under one version #
A browser entry and a server entry at different versions is the single most common cause of hydration mismatch in federated SSR. Publish them atomically.
// scripts/publish.mjs — one version, two entries, one manifest write
const version = process.env.VERSION;
await Promise.all([
uploadDir('dist/browser', `cart/browser/${version}/`),
uploadDir('dist/server', `cart/server/${version}/`),
]);
// Only after BOTH uploads succeed does the manifest start advertising them.
await patchManifest('cart', {
version,
browser: `https://cdn.example.com/cart/browser/${version}/remoteEntry.js`,
server: `https://cdn.example.com/cart/server/${version}/remoteEntry.js`,
});
Version-prefixed paths rather than content hashes make the pairing obvious and make rollback a single manifest edit. The same upload-then-advertise ordering applies as in validating remote manifests before loading.
Step 4 — Load the server container in the host #
The host fetches the server entry once, evaluates it, initialises the share scope, and caches the container keyed by its resolved URL.
// server/remote-loader.js
import vm from 'node:vm';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const containers = new Map(); // key: resolved URL, so a new version misses
async function getContainer(url) {
if (containers.has(url)) return containers.get(url);
const source = await fetch(url).then((r) => {
if (!r.ok) throw new Error(`remote server entry ${r.status}: ${url}`);
return r.text();
});
const mod = { exports: {} };
vm.runInNewContext(source, {
module: mod, exports: mod.exports, require,
console, URL, TextEncoder, TextDecoder, fetch, process,
}, { filename: url });
const container = mod.exports;
await container.init(__webpack_share_scopes__.default);
containers.set(url, container);
return container;
}
export async function loadServerRemote(name, module) {
const spec = (await getManifest())[name];
const container = await getContainer(spec.server);
const factory = await container.get(module);
return factory();
}
Keying the cache on the resolved URL rather than the remote name is what makes deploys work without a restart. When the manifest advertises a new version, the URL changes, the cache misses, and the new container is evaluated — no invalidation hook, no coordination.
Step 5 — Bound the load, and degrade when it fails #
The server must never let a remote hold the response. Wrap the load in a timeout and decide the fallback explicitly.
// server/safe-load.js
const TIMEOUT_MS = 400;
export async function safeLoadRemote(name, module) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`remote ${name} timed out`)), TIMEOUT_MS));
try {
return await Promise.race([loadServerRemote(name, module), timeout]);
} catch (err) {
// Report, then render a client-only placeholder for this region.
reportServerRemoteFailure(name, err);
return null;
}
}
Four hundred milliseconds is a reasonable starting point: long enough for a warm container, short enough that a cold or unhealthy remote does not dominate the response. Returning null rather than throwing lets the calling component decide what the region looks like, which is where the placeholder contract lives.
Step 6 — Give the client the same version the server used #
Hydration only works if the client loads the exact remote version the server rendered. Serialise the resolution into the HTML.
// server/render.js — the client must not resolve the manifest independently
const resolved = await resolveRemotesForRoute(req.path);
const html = renderToString(<App remotes={resolved} />);
res.send(`<!doctype html>
<html>
<head>${preloadTags(resolved)}</head>
<body>
<div id="root">${html}</div>
<script>window.__REMOTES__ = ${JSON.stringify(resolved).replace(/</g, '\\u003c')}</script>
<script src="/client.js" defer></script>
</body>
</html>`);
If the client fetched the manifest itself, a deploy landing between the server render and the client boot would give it a different version — and React would discard every byte of server markup. Escaping < in the serialised JSON prevents a </script> inside remote metadata from ending the script block early.
On the client, read the injected map instead of the manifest:
// client/load-remote.js
const RESOLVED = window.__REMOTES__ ?? {};
export const entryFor = (name) => RESOLVED[name]?.browser;
Step 7 — Prove request isolation before you ship #
A cached container means module-level state is shared across every request the process serves. Test for it explicitly rather than reviewing for it.
// test/request-isolation.spec.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('concurrent requests do not leak user data', async () => {
const [a, b] = await Promise.all([
renderRoute('/account', { user: { id: 'user-a', name: 'Ada' } }),
renderRoute('/account', { user: { id: 'user-b', name: 'Grace' } }),
]);
assert.ok(a.includes('Ada') && !a.includes('Grace'));
assert.ok(b.includes('Grace') && !b.includes('Ada'));
});
Run it with real concurrency, not sequentially — sequential requests pass even when module scope is being reused, because each one overwrites the previous value before rendering. The failure only appears when two renders interleave.
Verification #
- Both targets build:
dist/browser/remoteEntry.jsanddist/server/remoteEntry.jsboth exist after onenpm run build. - Exposed keys match: diff the
exposesmap between configurations in CI and fail on any difference. - The container evaluates:
node -e "require('./dist/server/remoteEntry.js')"should not throw. If it does, the remote touches a browser global at module scope. - HTML contains remote markup:
curl -s $URL | grep -c 'data-remote="cart"'should be non-zero before any JavaScript runs. - One React instance: log
require('react').versionfrom the host and from inside a remote render. They must be identical. - Deploys take effect: publish a new remote version and confirm the next request renders it without restarting the host.
Troubleshooting #
Symptom: document is not defined when the container is evaluated.
Diagnosis: the remote reads a browser global at module scope, so it throws on import rather than on render. Fix: move the access inside a function guarded by typeof window !== 'undefined', and add the node -e smoke test from the verification list to the remote’s CI.
Symptom: Shared module is not available for eager consumption on the server.
Diagnosis: the server entry imports shared modules synchronously, before the share scope is initialised. Fix: apply the same async bootstrap boundary the browser build uses — index.server.js should do nothing but import('./bootstrap.server').
Symptom: remote deploys have no effect until the host restarts.
Diagnosis: the container cache is keyed by remote name. Fix: key it on the resolved URL, as in step 4, so a version change is a cache miss.
Symptom: hydration replaces the whole server-rendered tree.
Diagnosis: the client resolved a different remote version than the server. Fix: serialise the resolution into the HTML as in step 6, and never let the client fetch the manifest on a page the server already rendered.
Symptom: memory grows steadily under load.
Diagnosis: the container cache is keyed on the resolved URL and never evicted, so every remote version ever resolved stays in memory for the life of the process. On a fleet that deploys several times a day this accumulates quietly. Fix: bound the cache — keep the current version per remote plus one previous, and drop anything older once no in-flight render still references it.
Symptom: the server build works locally and fails in the container image.
Diagnosis: the evaluated remote requires a package that exists in the developer’s node_modules but was pruned from the production image, because webpack-node-externals left it external. Fix: either bundle the dependency into the server build or add it to the host’s production dependencies deliberately. The externals list is a contract with the runtime environment, and it needs the same review as any other dependency decision.
What this setup does and does not give you #
With both targets published and the loader in place, the host can render any remote’s exposed module to HTML, remotes still deploy on their own schedule, and a new remote version takes effect on the next request without a restart. That is the whole of the mechanical problem.
What it does not give you is correctness under concurrency, agreement between the two renders, or graceful behaviour when a remote is unhealthy. Those are the subjects of the three companion pages, and each addresses a failure that only appears once this configuration is working: hydration mismatches when the trees disagree, streaming boundaries when one remote is slower than the rest, and server-side data fetching when the remote needs data the client must not fetch again.