# The Future of Micro-Frontends: How to Combine Module Federation with Server-Side Rendering (SSR)

Client-side Module Federation has a well-known weakness, the browser downloads a shell, then fetches remotes, then renders. Users stare at a blank page, and crawlers may see very little. For content-heavy or SEO-sensitive products, that's a deal-breaker, and it's why teams keep asking the same question, *can we get independently deployed micro-frontends* ***and*** *server-rendered HTML?*

The answer is yes, with caveats. Module Federation supports server-side rendering, and the same model that works in the browser transfers to Node, a host fetches code from remotes at runtime and renders it on the server. But SSR raises the stakes. You now have two runtimes to keep consistent, a network dependency inside your render path, and a security boundary you didn't have before.

This guide explains how the pieces fit, shows a working configuration pattern, and is candid about where the ecosystem is solid and where it isn't.

## How Module Federation Works with SSR

In client-only federation, a remote publishes a `remoteEntry.js`, and the browser loads it on demand. With SSR, each application builds **twice**, a browser bundle and a server bundle, each with its own federation entry.

```plaintext
Request ──► Host server (Node)
              │  1. fetch remote's SERVER entry (cached)
              │  2. execute remote code, render to HTML stream
              ▼
          HTML response ──► Browser
                              │  3. load remote's CLIENT entry
                              ▼
                          Hydration
```

Conceptually, the remotes act as static asset servers, the host's server pulls their server-side code and executes it in the same Node process that renders the page. That's what makes independent deployment possible, ship a new remote and the host picks it up without a rebuild, and it's also the source of most SSR-specific risk.

## Step 1 - Build the Remote Twice

The remote exposes a component and produces a server entry alongside the client one. A server federation config typically targets a Node environment and uses the `@module-federation/node` runtime plugin:

```bash
npm install @module-federation/enhanced @module-federation/node
```

```javascript
// remote/webpack.server.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');

module.exports = {
  target: 'async-node',
  output: { path: __dirname + '/dist/server', libraryTarget: 'commonjs-module' },
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      library: { type: 'commonjs-module' },
      exposes: { './ProductList': './src/ProductList' },
      runtimePlugins: [require.resolve('@module-federation/node/runtimePlugin')],
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
};
```

The client config mirrors our earlier micro-frontend setup (`target: 'web'`, output to `dist/client`). Serve both directories from the remote's static host so each has its own entry URL, for example `/server/remoteEntry.js` and `/client/remoteEntry.js`. Config details vary by version, so treat this as a pattern and confirm option names in the current docs.

## Step 2 - Load Remotes at Runtime on the Host

On the host's server, use the federation runtime to register and load remotes dynamically rather than hardcoding them at build time:

```typescript
// host/server/federation.ts
import { init, loadRemote } from '@module-federation/enhanced/runtime';

init({
  name: 'host',
  remotes: [
    { name: 'productApp', entry: `${process.env.PRODUCT_APP_URL}/server/remoteEntry.js` },
  ],
});

export async function loadProductList() {
  const mod = await loadRemote<{ default: React.ComponentType<any> }>('productApp/ProductList');
  return mod?.default ?? null;
}
```

Then render with a timeout and a fallback so a slow remote can't hang your page:

```typescript
// host/server/render.tsx
import { renderToPipeableStream } from 'react-dom/server';

export async function renderPage(res, props) {
  const ProductList = await withTimeout(loadProductList(), 800).catch(() => null);

  const { pipe } = renderToPipeableStream(
    <Layout>
      {ProductList ? <ProductList {...props} /> : <ProductListSkeleton />}
    </Layout>,
    { onShellReady: () => pipe(res) },
  );
}
```

If the remote can't be loaded in time, the server returns the shell with a skeleton, and the browser loads the client entry and fills it in. Users get a degraded-but-working page instead of an error.

## Step 3 - Hydration Without Mismatches

SSR only pays off if hydration succeeds. Three rules prevent most mismatches:

**Keep server and client versions identical.** The HTML produced by the remote's server build must match what its client build renders. Deploy both bundles together, under the same version identifier.

**Serialize the data you rendered with.** Embed the props or fetched data in the HTML and reuse them on the client, so the first client render doesn't refetch and produce different output.

**Share singletons on both sides.** React must be a single instance in both the server and client graphs. Duplicate React copies cause hook errors that only appear in production.

## Step 4 - Resilience in the Render Path

Putting a network call inside server rendering changes your failure model. Plan for it:

- **Cache remote entries.** Don't refetch `remoteEntry.js` on every request. Cache in memory with a short TTL, and refresh in the background.
- **Pin to immutable URLs.** Use content-hashed or versioned paths for server entries so a half-finished deploy can't serve mismatched files.
- **Set strict timeouts and circuit breakers.** After repeated failures, stop attempting the remote for a cooldown period and serve the fallback.
- **Monitor per-remote render time.** Add remote name and duration to your traces so a slow remote is identifiable in minutes. Our distributed tracing guide covers the instrumentation.

## Security: The Remote Is Now Code on Your Server

This is the part teams underestimate. In client federation, a compromised remote runs in the user's browser. With SSR federation, it runs **inside your server process**, with access to its environment, network, and secrets.

Treat remote entries as a supply-chain dependency:

- Load only from origins you control, over TLS.
- Never pass secrets or full request objects into remote components.
- Run the host's render process with least privilege and no unnecessary credentials in its environment.
- Where possible, verify integrity of fetched entries (hashes or signed manifests) before execution.

If you wouldn't `npm install` a package from that source, don't federate from it on the server.

## Framework and Tooling Reality Check

The ecosystem has shifted, and this matters if you're planning around a specific framework:

| Stack | Federation + SSR status |
| --- | --- |
| Webpack / Rspack with `@module-federation/enhanced` | Supported, server builds use the Node runtime plugin |
| Modern.js, Rsbuild plugin | Actively supported by the Module Federation project |
| Vite via `@module-federation/vite` | Available, Node/SSR consumption has had rough edges, so test carefully |
| Angular with Nx | Supported historically, Nx is moving server builds toward the standard federation plugin, so check migration notes for your version |
| Next.js | `@module-federation/nextjs-mf` supports the Pages Router only, and the Module Federation team announced end of support in late 2024 |

The Next.js row deserves emphasis. If your plan depends on federating Next.js applications, verify current status before committing, and have an alternative ready, separate deployments composed at the edge or gateway, or a framework with first-class federation support.

## When Not to Use Federation with SSR

Module Federation adds runtime coupling, versioning complexity, and debugging overhead. Consider simpler options first:

- **A monorepo with a shared component library** gives you reuse without runtime composition.
- **Server-side HTML composition** (a gateway stitching fragments from independent services) avoids executing remote code in your renderer.
- **Route-level separation** (each team owns whole pages on separate deployments) avoids composition entirely.

Federation with SSR earns its complexity when multiple teams must deploy independently *and* share a rendered page, with real SEO or first-paint requirements.

## Conclusion

Combining Module Federation with SSR closes the biggest gap in client-only micro-frontends, first paint and crawlability. The pattern is sound, build each application for browser and server, load remotes at runtime, hydrate with matching versions, but it moves a network dependency and untrusted-code risk into your render path.

The practical path forward is to pilot with one remote on a well-supported toolchain, add timeouts, caching, and fallbacks from day one, and treat server-side remotes as part of your supply chain. Where the framework support isn't there, choose a simpler composition strategy rather than fighting the tooling. The future of micro-frontends is not one pattern, it's choosing the lightest composition that meets your team's independence and performance needs.

*Running federation with SSR in production on Rspack, Modern.js, or Webpack? Share which toolchain you chose and what broke first in the comments.*

#MicroFrontends #ModuleFederation #SSR #ServerSideRendering #React
