Server-Side Rendering Federated Modules #
Module Federation was designed around a browser that fetches a script and evaluates it. Server-side rendering has neither a browser nor a script tag, which is why federating on the server is a genuinely different problem rather than the same configuration with a different target.
This guide covers what changes on the server, how the two module graphs are kept in agreement, and the failure modes — hydration mismatch, cache staleness, cross-runtime version drift — that only appear once a remote renders in both places. It sits under Webpack & Vite Module Federation Implementation and builds directly on managing shared dependencies at runtime. Its step-by-step companions cover configuring Node federation for SSR remotes, hydration mismatches in federated React apps, streaming SSR with remote component boundaries, and sharing data fetching between host and remote on the server.
What actually changes on the server #
Four assumptions the browser runtime makes are all false in Node.
There is no document, so a remote cannot be loaded by injecting a script element. The server has to fetch the remote’s server build and evaluate it in a module context, which means every remote needs a second build target.
There is no per-user isolation. A browser tab has one share scope belonging to one user; a Node process serves thousands of requests through one module registry. Anything a remote writes into module scope is visible to every subsequent request, which turns an ordinary singleton into a cross-request data leak.
There is no lazy network. On the client, an unavailable remote degrades to a spinner. On the server it either blocks the response or forces a decision about what to send instead, and that decision has to be made in milliseconds.
And there are now two graphs that must agree. The server renders HTML from one build of a remote; the client hydrates it from another. If those two builds are not the same version of the same code, React will discard the server’s markup and re-render — silently, and at the cost of everything SSR bought you.
Key objectives #
- Give every remote a server build with its own entry, published alongside the browser build and versioned together.
- Keep the server and client graphs identical per request, so hydration matches the markup that was sent.
- Isolate per-request state so a shared module cannot carry one user’s data into another user’s response.
- Fail fast and degrade deliberately when a remote cannot render on the server, rather than blocking the response.
- Preserve independent deployment, which is the reason for federating at all — an SSR design that requires lockstep releases has traded away the whole benefit.
Setup: two builds per remote #
Each remote produces a browser bundle and a Node bundle from the same source, with different targets and different library formats.
// remote/webpack.server.js — the SSR build of the same remote
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
target: 'async-node',
entry: './src/index.server.js',
output: {
path: __dirname + '/dist/server',
filename: '[name].js',
// CommonJS so the host's Node process can require the container.
library: { type: 'commonjs-module' },
publicPath: 'https://cdn.example.com/cart/server/',
},
plugins: [
new ModuleFederationPlugin({
name: 'cart',
filename: 'remoteEntry.js',
// The same exposed keys as the browser build — this is the contract.
exposes: { './Widget': './src/Widget' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
library: { type: 'commonjs-module' },
}),
],
};
The exposed keys must match the browser build exactly. They are the contract, and a key that exists in one target and not the other produces a remote that renders on the server and disappears on hydration — or the reverse, which is worse because it looks like it works.
Publish both builds under the same version, in the same release step. A manifest entry that carries a browser entry and a server entry with different versions is the root cause of most hydration mismatches in federated applications.
{
"cart": {
"version": "4.2.1",
"browser": "https://cdn.example.com/cart/browser/remoteEntry.a91f3c.js",
"server": "https://cdn.example.com/cart/server/remoteEntry.7de20b.js",
"integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}
}
Integration: loading a remote inside a Node process #
On the server the host fetches the remote’s server entry, evaluates it, initialises the share scope, and calls the factory — the same four steps as the browser, implemented differently.
// server/load-remote.js
import vm from 'node:vm';
import { readFile } from 'node:fs/promises';
const containers = new Map();
export async function loadServerRemote(name, module, entryUrl) {
if (!containers.has(name)) {
const source = await fetchWithCache(entryUrl);
const script = new vm.Script(source, { filename: entryUrl });
const sandbox = { module: { exports: {} }, exports: {}, require, console, URL, fetch };
script.runInNewContext(sandbox);
const container = sandbox.module.exports;
await container.init(__webpack_share_scopes__.default);
containers.set(name, container);
}
const factory = await containers.get(name).get(module);
return factory();
}
Caching the evaluated container is necessary — re-evaluating a remote per request would be ruinous — and it is also the source of the two hardest problems here. A cached container holds a specific version of the remote until the process restarts, so a remote deploy does not take effect until you invalidate. And any module-level state inside that container is now shared across every request the process handles.
Both are solvable, and both must be solved deliberately rather than discovered.
Integration: keeping per-request state out of module scope #
The rule is simple and absolute: nothing request-specific may live in a module-level variable on the server.
// WRONG — module scope on the server is shared across every request
let currentUser = null;
export function setUser(u) { currentUser = u; }
// RIGHT — request context flows explicitly, or through AsyncLocalStorage
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage();
export function getUser() {
const ctx = requestContext.getStore();
if (!ctx) throw new Error('getUser called outside a request scope');
return ctx.user;
}
Throwing when there is no store is deliberate. A version that returned undefined would let a remote render a logged-out view on the server and a logged-in one on the client, producing a hydration mismatch that is very hard to trace back to its cause.
This constraint reaches into the patterns from cross-app state and context sharing: a shared store that is a singleton in the browser must be created per request on the server, and any remote that assumes otherwise will leak.
Edge cases #
A remote has no server build. This is common when adopting SSR incrementally. Render a placeholder on the server and load the remote only on the client, marking the boundary explicitly so React does not treat the difference as a mismatch.
The remote’s server entry is unreachable. The server must not wait. Set a short timeout — a few hundred milliseconds — and fall back to client-only rendering for that region, because a slow remote must never hold the whole document.
Two remotes need different versions of a shared library. In the browser the share scope negotiates and, at worst, loads a second copy. In one Node process a second copy of React means two renderers, which will not produce coherent HTML. Version alignment is stricter on the server than in the browser.
The remote reads window at module scope. It will throw on import rather than at render time, taking down the whole response. Guard access behind a runtime check, and make server builds fail loudly in CI when a remote touches browser globals during evaluation.
Testing and validation #
| Check | Runs where | Catches |
|---|---|---|
| Exposed-key parity between targets | Remote CI | A module exposed in one build and not the other |
| Version parity in the manifest | Publish job | Browser and server entries at different versions |
| Hydration mismatch assertion | Host CI, per route | Server markup the client refuses to reuse |
| Request isolation test | Host CI | Module-scope state leaking between requests |
| Remote-unavailable simulation | Host CI | The server blocking instead of degrading |
The request isolation test is the one worth building first, because it catches the failure with the worst consequences. Render two requests concurrently with different users and assert that neither response contains the other’s data. It is a short test and it has found real bugs in every federated SSR system I have seen it applied to.
Deployment #
The deployment shape follows from the caching problem. Because the server caches evaluated containers, a remote deploy has no effect until the host process learns about it, which means one of three strategies.
The simplest is a short manifest TTL plus a container cache keyed by resolved URL. When the manifest changes, the URL changes, the cache misses, and the new version is fetched — no restart, no coordination. This is the same indirection that makes CDN cache invalidation work on the client, and it is the recommended default.
The second is an explicit invalidation hook: the remote’s pipeline calls an endpoint on the host after publishing. It is faster but couples the two deployments, and it fails quietly when the endpoint is unreachable.
The third — restarting host processes on every remote deploy — is common and is exactly the coupling federation exists to remove. Treat it as a temporary measure.
Whichever you choose, keep the browser and server entries on the same version at all times. Because a user’s page load spans both, a window in which they disagree is a window of hydration mismatches.
Deciding which remotes to server-render at all #
Server-rendering every remote is rarely the right target, and treating it as one is why these migrations stall. The decision is per remote and per route, and it has a clear economic shape: server rendering pays for content that must appear in the first paint or in a crawler’s view of the page, and costs latency for everything else.
A product description, a price, a headline and the primary call to action belong in the server-rendered response. A recommendations carousel, a live chat launcher, a personalisation widget and anything behind a tab do not — they can hydrate later without anyone noticing, and each one you exclude removes a dependency from the critical server path.
This also gives incremental adoption a natural order. Start with the remote that owns the route’s main content, because it delivers most of the benefit on its own. Add others only when there is a measurable reason. A federated page where two of six remotes render on the server and four render on the client is not a half-finished migration — it is frequently the correct end state.
The per-request decision matters as much as the per-remote one. A remote with a healthy server build should still be skipped on a request where its entry is slow to evaluate or its data dependency is timing out. Treating server rendering as best-effort per request, with a deterministic client fallback, is what keeps a single unhealthy remote from becoming a site-wide latency incident.
Team topology for a federated SSR estate #
Server rendering changes who is on call for what, and the change is easy to miss because nothing in the architecture diagram moves.
On the client, a broken remote is contained by an error boundary and affects one region of one user’s page. On the server, a remote that throws during evaluation, leaks memory, or blocks on a slow dependency degrades a shared process that is serving everyone. The remote team wrote the code; the platform team owns the process it runs in and will be paged when it falls over.
Three arrangements keep that workable. First, the host must treat every remote as untrusted for availability purposes — timeouts on evaluation and rendering, a circuit breaker per remote, and an explicit fallback that is exercised in tests rather than assumed. Second, remote teams need a server-side test harness in their own pipeline, so “does this render in Node without touching window” is answered before publishing rather than during an incident. Third, telemetry has to attribute server-side render time and server-side errors per remote, in the same way the client attributes them — otherwise the platform team can see that responses are slow but not which team to talk to.
The circuit breaker is the piece most often skipped and the one that changes the incident profile most. A remote that has failed to render five times in a row should be skipped automatically for a cooling-off period, falling back to client rendering, rather than being retried on every request. That converts a remote outage from a site-wide latency spike into a temporary loss of server-rendered content for one region — which is the same containment the client gets from an error boundary, applied to the shared process.
Common pitfalls #
| Pitfall | Root cause and resolution |
|---|---|
| One user sees another user’s data | Request state in module scope. Move it into AsyncLocalStorage or pass it explicitly. |
| Hydration discards the server markup | Server and client resolved different remote versions. Pin both entries to one manifest version. |
| A slow remote delays the whole page | Server awaits the remote without a timeout. Bound it and fall back to client rendering. |
| Remote deploys do not take effect | Evaluated container cached by name, not by URL. Key the cache on the resolved URL. |
| Server build crashes on import | Remote touches window at module scope. Guard it and test the server build in CI. |
| Two React copies in one process | Version negotiation resolved differently on the server. Align versions and use strictVersion. |
FAQ #
Is SSR with Module Federation worth the complexity?
It is worth it when the composed page has to be fast on first paint or indexable by crawlers, and the remotes are owned by teams that must keep deploying independently. If only the first is true, pre-rendering or partial hydration is usually cheaper. If only the second is true, client-side federation is fine.
Can a remote be server-rendered by one host and client-rendered by another?
Yes, and this is a good reason to keep the two builds cleanly separated. A remote should not assume which environment it is in; it should read that from the context the host provides, exactly as it reads locale or theme.
How do we handle a remote that only exists on the client?
Render a deterministic placeholder on the server and load the remote after hydration. React needs the server and client trees to agree at that position, so the placeholder must render identically in both — a useEffect-gated swap is the standard approach and is covered in hydration mismatches in federated React apps.
Does streaming SSR work with federated remotes?
Yes, and it fits well: each remote becomes a Suspense boundary that flushes when its content is ready, so one slow remote no longer delays the rest of the document. The details are in streaming SSR with remote component boundaries.
What about Vite-based remotes?
The same principles apply, but the plugin ecosystem is less mature for the Node target. In a mixed estate, the reliable pattern is a Webpack host with Vite remotes that publish an ESM server build the host imports directly — see sharing singletons across Webpack and Vite remotes.
Should the server fetch data for remote components?
Only through an explicit contract. A remote that fetches its own data on the server will do it again on the client unless that data is serialised into the page, which is the whole subject of sharing data fetching between host and remote on the server.