Module Federation Between Vite and Next.js Without Breaking SSR
How to wire React Vite microfrontends into a Next.js host with Module Federation — avoiding duplicate React crashes, hydration mismatches, and shared dependency conflicts.
Module Federation Between Vite and Next.js Without Breaking SSR
Module Federation lets separate teams ship separate bundles that still behave like one app at runtime. That's the pitch, anyway. The moment you try to federate a Vite-built remote into a Next.js host, you run into three problems the pitch never mentions: two copies of React fighting over the same page, a server that renders nothing where the remote is supposed to be, and a hydration error that shows up in production but not in dev.
None of these are edge cases. They're the default behavior. Here's how to actually make the combination work.
Why duplicate React instances crash the page
If your remote ships its own copy of react, you'll see Invalid hook call. Hooks can only be called inside the body of a function component — even though the component looks completely correct. React hooks rely on a single module instance holding internal state; two copies means two separate internal states, and calling a hook from "the wrong" React instance breaks instantly.
The fix is to force both host and remote onto the exact same singleton. On the remote side, declare it with a real version constraint:
// remote-app/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'remote_dashboard',
filename: 'remoteEntry.js',
exposes: {
'./AnalyticsWidget': './src/components/AnalyticsWidget.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.3.0' },
'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
},
}),
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
});
singleton: true is what actually matters here. requiredVersion just makes the failure loud and early instead of silent and weird three components deep.
Why Next.js hydration breaks on federated components
Next.js renders on the server first, then the browser reconciles that HTML against a client-side render. A federated remote lives on a separate CDN and loads asynchronously — the server has no way to render it, so it renders nothing, and the client render doesn't match. That mismatch is what throws the hydration error, and it's not a bug you can configure around; it's a structural consequence of where the remote code lives.
The practical answer is to stop asking Next.js to SSR it at all. Load the remote client-side only, with a loading state and a fallback for when the CDN is unreachable:
// next-host/components/FederatedAnalyticsWidget.tsx
'use client';
import dynamic from 'next/dynamic';
const RemoteWidget = dynamic(
() =>
import('remote_dashboard/AnalyticsWidget').catch((err) => {
console.error('Failed to load remote AnalyticsWidget:', err);
return () => (
<div className="p-4 bg-red-50 text-red-700 rounded border border-red-200">
Analytics module currently unavailable.
</div>
);
}),
{
ssr: false,
loading: () => (
<div className="h-64 animate-pulse bg-slate-100 rounded-lg flex items-center justify-center text-slate-400">
Loading remote widget...
</div>
),
}
);
export function FederatedAnalyticsWidget({ organizationId }: { organizationId: string }) {
return <RemoteWidget organizationId={organizationId} />;
}
The .catch() isn't defensive boilerplate — it's the difference between a dead CDN taking down your dashboard page and it just showing one greyed-out widget. Treat remote loading as a network call that can fail, because that's exactly what it is.
Wiring the host itself
The host side needs its own federation config, pointing at the remote's deployed entry file and matching the shared scope:
// next-host/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
if (!options.isServer) {
config.plugins.push(
new NextFederationPlugin({
name: 'host_app',
filename: 'static/chunks/remoteEntry.js',
remotes: {
remote_dashboard: 'remote_dashboard@https://cdn.yourdomain.com/remote_dashboard/assets/remoteEntry.js',
},
shared: {
react: { singleton: true, eager: true, requiredVersion: false },
'react-dom': { singleton: true, eager: true, requiredVersion: false },
},
})
);
}
return config;
},
};
Two things worth calling out that are easy to copy wrong. !options.isServer matters — you only want this plugin touching the client webpack config, since federation on the server bundle doesn't make sense with ssr: false remotes. And requiredVersion: false on the host is intentional, not a shortcut: the host doesn't know at build time which remote versions it'll be talking to at runtime, so pinning a version here just breaks deploys unnecessarily. Set eager: true on the host and let the remote's own requiredVersion do the actual enforcement.
Is Module Federation even the right call?
| Approach | SSR support | Independent deployment | Shared cache | Fault isolation |
|---|---|---|---|---|
| NPM package dependency | Full | Requires host redeploy | High | Low |
| Iframe embedding | None | Full | None | High (sandboxed) |
| Vite + Next.js Module Federation | Client-only | Full, instant CDN update | Full, shared React | High, with error boundaries |
If independent deployment is the only thing you actually need, an iframe is simpler and you should probably just use one — you give up SSR and shared state, but you also give up singleton version negotiation, remote-entry CDN configuration, and hydration debugging. Module Federation earns its complexity specifically when a remote team needs to ship on its own schedule and the remote component needs to share React context, a query cache, or design-system state with the host. Take away either half of that requirement and you're paying for infrastructure you don't need.
Where it does earn its keep, the pattern above — singleton shared deps, ssr: false with a real fallback, and a host that stays version-agnostic — is what keeps a remote CDN outage from becoming a host outage.
Accelerate your Frontend & Web Architecture Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Your Vite Bundle Is Probably 1.5MB for No Good Reason
Fix slow LCP and INP in React + Vite apps with route-level code splitting, manual Rollup chunks, and smarter icon imports. Real config included.
Your Next.js Proxy Is Probably Adding 300ms to Every Request
How to build a fast, edge-compatible proxy.ts in Next.js App Router — stateless JWT auth with jose, HTTP-based Redis rate limiting, and a matcher config that stops running on every image request.
Next.js Server Components + TanStack Query: Fixing Double-Fetches and Cache Drift
How to pair Next.js App Router Server Components with TanStack Query v5 without double-fetching, leaking data across requests, or serving stale caches after mutations.