TL;DR: Break down your Next.js monolith into independently deployable micro-frontends using the App Router and Webpack Module Federation to eliminate build bottlenecks. This guide demonstrates how to configure Host and Remote applications using the
@module-federation/nextjs-mfplugin, including specificnext.config.jssetups to handle both SSR and client-side remote entries without hydration mismatches.
⚡ Key Takeaways
- Install and configure the
@module-federation/nextjs-mfplugin in both Host and Remote applications to enable dynamic runtime code loading. - Use the ternary operator
isServer ? 'ssr' : 'chunks'in your Host'snext.config.jsremote paths to correctly handle Next.js server-side rendering and prevent hydration mismatches. - Configure core libraries like
reactandreact-domwith{ singleton: true, eager: true }in the Webpack shared configuration to prevent duplicate dependencies across the network. - Manage remote endpoints dynamically using environment variables (e.g.,
process.env.NEXT_PUBLIC_CHECKOUT_URL) to seamlessly switch between Dev, Staging, and Prod environments. - Enforce strict repository separation for remote domains (like Checkout or Dashboard) to achieve true CI/CD pipeline autonomy instead of relying solely on monorepos.
Your Next.js repository has become a bottleneck. When you had five engineers, your monolithic architecture was a superpower. Code sharing was frictionless, global refactoring was a single PR, and deployments were instantaneous.
Now you have forty engineers spanning five different product domains. Build times have crept from two minutes to twenty-five. A broken dependency in the internal admin dashboard blocks a critical hotfix for the customer checkout flow. Your Vercel deployment queue is constantly backlogged, and engineers are spending more time resolving complex package-lock.json merge conflicts than shipping features. Conway's Law is aggressively working against your single-repository architecture.
Throwing more engineers at a monolithic frontend doesn't increase velocity; it increases friction. To regain agility, you need to decouple your domains. You need Micro-Frontends (MFEs).
By leveraging the Next.js App Router, Webpack Module Federation, and TypeScript, you can fracture your monolithic application into autonomous, independently deployable domains that seamlessly merge into a single user experience at runtime.
Here is the technical blueprint for achieving frontend autonomy.
The Architecture: Hosts, Remotes, and the Next.js App Router
Webpack Module Federation flips the traditional build process on its head. Instead of bundling all code at build time, it allows JavaScript applications to dynamically load code from other applications at runtime.
In this architecture, we define two primary actors:
- The Host (or Shell): The primary Next.js application that handles global routing, layout, and cross-cutting concerns (like authentication).
- The Remote: An independent Next.js application (e.g., Checkout, Dashboard, Inventory) that exposes specific React components or functions to the Host.
To set this up, we need multiple independent projects. Let's initialize our workspace structure:
# Initialize a workspace for our micro-frontends
mkdir enterprise-mfe-workspace && cd enterprise-mfe-workspace
# Create the Host application
npx create-next-app@latest host-app --typescript --tailwind --eslint --app
# Create a Remote application (e.g., Checkout domain)
npx create-next-app@latest remote-checkout --typescript --tailwind --eslint --app
# Install the required Webpack Module Federation plugin for Next.js in BOTH apps
cd host-app && npm install @module-federation/nextjs-mf
cd ../remote-checkout && npm install @module-federation/nextjs-mf
Strategic Tip: When providing full-stack web development services for enterprise clients, we strictly enforce repository separation for remotes. While monorepos (like Turborepo) are great, true deployment autonomy is best achieved when remotes live in separate CI/CD pipelines.
Configuring the Next.js Host Application
The host application is responsible for orchestrating the user interface. It needs to know where to find the remote applications at runtime. Because Next.js handles Webpack configuration internally, we must inject the NextFederationPlugin into the next.config.js file.
Open the next.config.js in your host-app and configure it as follows:
// host-app/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack(config, options) {
const { isServer } = options;
config.plugins.push(
new NextFederationPlugin({
name: 'host',
filename: 'static/chunks/remoteEntry.js',
remotes: {
// Define the remote endpoints. We use environment variables to manage environments (Dev/Staging/Prod)
checkout: `checkout@${process.env.NEXT_PUBLIC_CHECKOUT_URL}/_next/static/${
isServer ? 'ssr' : 'chunks'
}/remoteEntry.js`,
},
exposes: {
// The host can also expose shared components, like a UI library
'./SharedButton': './src/components/SharedButton.tsx',
},
shared: {
// Ensure React and Next singletons are not duplicated across the network
react: { singleton: true, eager: true, requiredVersion: false },
'react-dom': { singleton: true, eager: true, requiredVersion: false },
},
})
);
return config;
},
};
module.exports = nextConfig;
Notice the ternary operator isServer ? 'ssr' : 'chunks'. Next.js runs both on the server and the client. Module Federation in Next.js requires specific remote entries depending on where the execution is happening to prevent hydration mismatches.
Building and Exposing the Remote Domain
The Checkout team operates autonomously. They have their own backlog, their own release schedule, and their own repository. Their application needs to expose a cohesive feature—such as a complex CheckoutFlow component—to the Host.
First, let's look at the next.config.js for the remote-checkout application:
// remote-checkout/next.config.js
const NextFederationPlugin = require('@module-federation/nextjs-mf');
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'checkout',
filename: 'static/chunks/remoteEntry.js',
exposes: {
// Expose the CheckoutFlow component to be consumed by the Host
'./CheckoutFlow': './src/components/CheckoutFlow.tsx',
},
shared: {
react: { singleton: true, eager: true, requiredVersion: false },
'react-dom': { singleton: true, eager: true, requiredVersion: false },
},
})
);
return config;
},
};
module.exports = nextConfig;
Now, let's build the component being exposed. Because Webpack Module Federation interacts deeply with Next.js internals, it is highly recommended to expose Client Components when working within the App Router, as Server Components have strict serialization boundaries.
// remote-checkout/src/components/CheckoutFlow.tsx
'use client';
import React, { useState } from 'react';
export default function CheckoutFlow() {
const [step, setStep] = useState(1);
return (
<div className="p-6 bg-white rounded-lg shadow-md border border-gray-200">
<h2 className="text-2xl font-bold mb-4">Secure Checkout</h2>
{step === 1 ? (
<div>
<p>Review your items</p>
<button
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
onClick={() => setStep(2)}
>
Proceed to Payment
</button>
</div>
) : (
<div>
<p>Enter payment details</p>
<button
className="mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition"
onClick={() => alert('Order Placed!')}
>
Pay Now
</button>
</div>
)}
</div>
);
}
Production Note: Never expose Next.js
page.tsxorlayout.tsxfiles directly as remotes. Always expose standard React components. Routing should remain the responsibility of the application rendering the component.
Dynamic Hydration: Stitching Remotes in the App Router
The Host application now needs to render the CheckoutFlow component. Since the code for this component does not exist in the Host's build artifact, we must fetch it over the network using next/dynamic.
Network requests can fail. A robust micro-frontend architecture must account for remote unavailability without crashing the entire Host application. We achieve this by wrapping the dynamic import in an Error Boundary and a React Suspense fallback.
// host-app/src/app/checkout/page.tsx
'use client';
import React, { Suspense } from 'react';
import dynamic from 'next/dynamic';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
// Dynamically import the federated component
// The syntax is 'remoteName/exposedModule'
const RemoteCheckoutFlow = dynamic(() => import('checkout/CheckoutFlow'), {
ssr: false, // Enforce client-side rendering for this remote
loading: () => <div className="animate-pulse bg-gray-200 h-64 w-full rounded-lg">Loading Checkout...</div>,
});
function RemoteErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
<h3 className="font-bold">Failed to load Checkout Module</h3>
<p className="text-sm">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-2 text-sm underline hover:text-red-700"
>
Try again
</button>
</div>
);
}
export default function CheckoutPage() {
return (
<main className="max-w-4xl mx-auto py-12">
<h1 className="text-4xl font-extrabold mb-8">Complete Your Purchase</h1>
<ErrorBoundary FallbackComponent={RemoteErrorFallback}>
<Suspense fallback={<p className="text-gray-500">Initializing secure connection...</p>}>
<RemoteCheckoutFlow />
</Suspense>
</ErrorBoundary>
</main>
);
}
Typing the Void: TypeScript Integration Across Repositories
If you try to run the code above, TypeScript will immediately throw an error: Cannot find module 'checkout/CheckoutFlow' or its corresponding type declarations.
Because the remote code is fetched at runtime, the TypeScript compiler has no knowledge of it at build time. To maintain type safety—a core requirement for any enterprise team—we must implement a declaration strategy.
The simplest approach is ambient module declarations. Create a .d.ts file in your Host application to stub the remote modules:
// host-app/types/remotes.d.ts
declare module 'checkout/CheckoutFlow' {
import React from 'react';
export interface CheckoutFlowProps {
cartId?: string;
onComplete?: (orderId: string) => void;
}
const CheckoutFlow: React.ComponentType<CheckoutFlowProps>;
export default CheckoutFlow;
}
For advanced, automated typing, you can implement the @module-federation/typescript plugin. This plugin extracts .d.ts files during the Remote's build process and serves them as a federated asset, allowing the Host to download the types dynamically during development.
Sharing State and Singletons
One of the most complex challenges in a micro-frontend architecture is State Synchronization. If the Host handles user authentication, how does the Remote know if the user is logged in?
You must avoid prop-drilling complex data across federation boundaries. Instead, utilize shared singletons. By configuring a state management library (like Zustand or React Context) as a shared dependency in next.config.js, both applications will read from the exact same memory reference.
Here is how you define a shared authentication store using Zustand:
// host-app/src/store/authStore.ts (Exposed to remotes)
import { create } from 'zustand';
interface User {
id: string;
name: string;
}
interface AuthState {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}));
In your next.config.js for both host and remote, ensure Zustand is treated as a singleton:
// Add this to the 'shared' object in next.config.js
shared: {
// ...react config
zustand: { singleton: true, eager: false, requiredVersion: false },
}
The remote application can now safely import and consume this state, guaranteeing it shares the identical session data as the host shell.
Independent CI/CD Pipelines
The entire purpose of this architecture is to achieve independent deployment pipelines. The Checkout team should be able to deploy v2.0 of their domain without requiring the Host team to rebuild or redeploy the shell application.
Because the Host fetches remoteEntry.js at runtime, deploying a remote is as simple as overwriting the static assets in your cloud storage or CDN.
Here is a conceptual GitHub Actions workflow for deploying the Remote application independently:
# remote-checkout/.github/workflows/deploy.yml
name: Deploy Checkout Remote
on:
push:
branches: ["main"]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build Next.js Remote
run: npm run build
# The output lives in .next/static
# Sync ONLY the static assets to your CDN/S3 bucket
- name: Sync to AWS S3 (Production Remote Location)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
aws s3 sync .next/static s3://my-enterprise-cdn/remotes/checkout/_next/static --delete
When this pipeline completes, the next user who loads the Host application will naturally fetch the updated remoteEntry.js from the CDN, instantly receiving the new checkout features without a host deployment.
To understand how this deployment isolation changes daily engineering workflows, read about how we structure our pods and sprints for parallel execution.
Reclaiming Your Engineering Velocity
Scaling an engineering organization is rarely about writing better algorithms; it is almost entirely about reducing human friction. By transitioning your monolithic Next.js repository to a micro-frontend architecture using Webpack Module Federation, you eliminate merge queues, isolate deployment failures, and allow domain-specific teams to operate with complete autonomy.
The initial setup requires a rigorous approach to routing, state sharing, and TypeScript boundaries. However, once the infrastructural shell is established, the velocity gains are exponential. Your build times drop back to seconds, your teams stop stepping on each other's toes, and your organization scales harmoniously.
If your Next.js monolith has reached its tipping point and is actively slowing down your product roadmap, it might be time to fracture the frontend. Book a free architecture review with our senior engineering team to discuss your migration strategy.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native & Flutter apps for global clients.
Frequently Asked Questions
What are the most common causes of unnecessary re-renders in React?
Unnecessary re-renders typically occur when component state or props change without needing to update the DOM, often due to inline object creation or unmemoized callback functions. Using React Developer Tools' Profiler can help identify these rendering bottlenecks. If your team is struggling to pinpoint these issues at scale, SoftwareCrafting services can provide in-depth code audits to optimize your application's rendering cycles.
When should I use the useMemo and useCallback hooks?
You should use useMemo to cache expensive calculations and useCallback to memoize functions passed as props to deeply nested child components. Overusing these hooks can actually degrade performance due to the overhead of memory allocation and dependency tracking. It is always best practice to measure your component's performance before and after implementing them to ensure a tangible benefit.
How does code splitting improve application load times?
Code splitting divides your application bundle into smaller chunks that are loaded on demand, rather than sending a single massive JavaScript file to the client. This significantly reduces the initial load time and Time to Interactive (TTI) for your users. Developers typically implement this using React.lazy() and Suspense for route-based or heavy component-based splitting.
What is the performance difference between client-side rendering (CSR) and server-side rendering (SSR)?
CSR sends a barebones HTML document and relies on JavaScript to render the UI in the browser, which can lead to slower initial page loads on lower-end devices. SSR pre-renders the HTML on the server, delivering a fully formed page to the user much faster and drastically improving SEO metrics. Deciding between these architectures can be complex, but SoftwareCrafting services can help you design and implement the optimal rendering strategy for your specific business needs.
How can I optimize images and static assets in a modern web app?
Images should be served in modern, highly compressed formats like WebP or AVIF and sized appropriately for the user's specific device viewport. Implementing lazy loading for images below the fold prevents the browser from downloading unseen assets during the initial page load. Utilizing a Content Delivery Network (CDN) is also crucial for reducing network latency when serving these static files globally.
📎 Full Code on GitHub Gist: The complete
unresolved-template-error.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
