Next.js Micro-Frontends
Introduction
Micro-frontends extend the concept of microservices to the frontend world, allowing teams to build, test, and deploy UI components independently while creating a cohesive user experience. This architectural approach breaks down monolithic frontend applications into smaller, more manageable pieces that can be developed and deployed independently.
In this guide, we'll explore how to implement micro-frontends using Next.js, one of the most popular React frameworks. You'll learn different approaches to micro-frontend architecture, their benefits and challenges, and how to set up a working micro-frontend system using Next.js.
What Are Micro-Frontends?
Micro-frontends are an architectural style where a frontend application is decomposed into individual, semi-independent "microapps" that can be built, tested, and deployed independently while still appearing to users as a single cohesive product.
Key characteristics include:
- Independent development: Different teams can work on different parts of the application without stepping on each other's toes
- Technology agnosticism: Teams can choose the best technology stack for their micro-frontend
- Independent deployment: Each micro-frontend can be deployed independently
- Team autonomy: Teams can own their part of the product end-to-end
Why Use Micro-Frontends with Next.js?
Next.js offers several features that make it well-suited for micro-frontend architectures:
- Server-side rendering and static site generation: Excellent for performance and SEO
- Built-in routing system: Simplifies navigation between micro-frontends
- API routes: Facilitates backend communication
- Module Federation support: Through webpack plugins
- Incremental Static Regeneration: Updates static content without full rebuilds
Common Approaches to Micro-Frontends
1. Module Federation
Module Federation (introduced in Webpack 5) allows JavaScript applications to dynamically import code from other applications. This approach lets you share components and logic between different Next.js applications at runtime.
2. iFrames
The simplest but most isolated approach where each micro-frontend is loaded in an iFrame.
3. Web Components
Using browser-native Web Components to create reusable custom elements.
4. Build-time Integration
Importing micro-frontends as packages during the build process.
5. Run-time Integration via JavaScript
Loading different micro-frontends via JavaScript and mounting them in designated DOM elements.
Implementing Module Federation with Next.js
Setting Up Module Federation
First, install the required dependencies:
npm install @module-federation/nextjs-mf
1. Configure the Host Application
Create or modify your next.config.js
file in your host application:
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
const { isServer } = options;
config.plugins.push(
new NextFederationPlugin({
name: 'host',
remotes: {
remote1: `remote1@http://localhost:3001/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
remote2: `remote2@http://localhost:3002/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
},
filename: 'static/chunks/remoteEntry.js',
exposes: {
'./Header': './components/Header.js',
'./Footer': './components/Footer.js',
'./AuthContext': './context/AuthContext.js',
},
shared: {
react: {
singleton: true,
requiredVersion: false,
},
'react-dom': {
singleton: true,
requiredVersion: false,
},
},
}),
);
return config;
},
};
2. Configure the Remote Applications
For each remote application, create a similar configuration:
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config, options) {
const { isServer } = options;
config.plugins.push(
new NextFederationPlugin({
name: 'remote1',
filename: 'static/chunks/remoteEntry.js',
exposes: {
'./ProductList': './components/ProductList.js',
'./ProductDetail': './components/ProductDetail.js',
},
remotes: {
host: `host@http://localhost:3000/_next/static/${isServer ? 'ssr' : 'chunks'}/remoteEntry.js`,
},
shared: {
react: {
singleton: true,
requiredVersion: false,
},
'react-dom': {
singleton: true,
requiredVersion: false,
},
},
}),
);
return config;
},
};
3. Create Dynamic Components for Loading Remote Components
In your host application, create a dynamic component for loading remote components:
// components/RemoteComponent.js
import React, { Suspense } from 'react';
import dynamic from 'next/dynamic';
const RemoteComponent = ({ module, scope, component, fallback, ...props }) => {
const Component = dynamic(
() => import(`${scope}/${module}`).then((factory) => factory[component || 'default']),
{
loading: () => fallback || <div>Loading...</div>,
ssr: false,
}
);
return (
<Suspense fallback={fallback || <div>Loading...</div>}>
<Component {...props} />
</Suspense>
);
};
export default RemoteComponent;
4. Use the Remote Component in Your Pages
// pages/products.js
import React from 'react';
import RemoteComponent from '../components/RemoteComponent';
function ProductsPage() {
return (
<div className="container">
<h1>Products</h1>
<RemoteComponent
scope="remote1"
module="./ProductList"
fallback={<div>Loading Product List...</div>}
/>
</div>
);
}
export default ProductsPage;
Real-World Example: E-commerce Site with Micro-Frontends
Let's consider a practical example of an e-commerce site with different teams working on different sections:
- Team 1: Product catalog and search (remote1)
- Team 2: User account management (remote2)
- Team 3: Shopping cart and checkout (remote3)
- Core Team: App shell, navigation, footer (host)
Directory Structure
/e-commerce-micro-frontends
/host (port 3000)
/components
Header.js
Navigation.js
Footer.js
RemoteComponent.js
/pages
index.js
[...slug].js
next.config.js
/product-catalog (port 3001)
/components
ProductList.js
ProductDetail.js
SearchBar.js
/pages
index.js
next.config.js
/user-account (port 3002)
/components
UserProfile.js
OrderHistory.js
Settings.js
/pages
index.js
next.config.js
/shopping-cart (port 3003)
/components
Cart.js
Checkout.js
OrderSummary.js
/pages
index.js
next.config.js
Implementation Example: Host Application
// host/pages/index.js
import React from 'react';
import Head from 'next/head';
import Header from '../components/Header';
import Footer from '../components/Footer';
import RemoteComponent from '../components/RemoteComponent';
export default function Home() {
return (
<div>
<Head>
<title>E-commerce Store | Home</title>
<meta name="description" content="Our awesome e-commerce store" />
</Head>
<Header />
<main>
<section className="hero">
<h1>Welcome to Our Store</h1>
<p>Discover amazing products at great prices</p>
</section>
<section className="featured-products">
<h2>Featured Products</h2>
<RemoteComponent
scope="remote1"
module="./ProductList"
fallback={<div>Loading featured products...</div>}
featured={true}
limit={4}
/>
</section>
<section className="user-recommendations">
<h2>Recommended for You</h2>
<RemoteComponent
scope="remote1"
module="./ProductList"
fallback={<div>Loading recommendations...</div>}
recommended={true}
limit={4}
/>
</section>
</main>
<Footer />
</div>
);
}
Implementation Example: Product Detail Page
// host/pages/product/[id].js
import React from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import Header from '../../components/Header';
import Footer from '../../components/Footer';
import RemoteComponent from '../../components/RemoteComponent';
export default function ProductDetail() {
const router = useRouter();
const { id } = router.query;
return (
<div>
<Head>
<title>Product Details | E-commerce Store</title>
</Head>
<Header />
<main className="container">
<RemoteComponent
scope="remote1"
module="./ProductDetail"
fallback={<div>Loading product details...</div>}
productId={id}
/>
<div className="add-to-cart-section">
<RemoteComponent
scope="remote3"
module="./AddToCart"
fallback={<div>Loading add to cart button...</div>}
productId={id}
/>
</div>
</main>
<Footer />
</div>
);
}
Sharing State Between Micro-Frontends
One common challenge with micro-frontends is sharing state. Here are some approaches:
1. URL Parameters
Use the URL to share information between micro-frontends.
2. Custom Events
Communicate between micro-frontends using the browser's custom event system:
// In one micro-frontend
window.dispatchEvent(new CustomEvent('addToCart', {
detail: {
productId: '123',
name: 'Awesome Product',
price: 29.99
}
}));
// In another micro-frontend
window.addEventListener('addToCart', (event) => {
const product = event.detail;
// Handle adding to cart
console.log(`Adding ${product.name} to cart`);
});
3. Shared State Management
Using a shared state management library like Redux:
// In your host app's next.config.js, add redux to shared modules
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
redux: { singleton: true },
'react-redux': { singleton: true }
}
Then create a shared store that can be imported by all micro-frontends.
Performance Considerations
- Lazy Loading: Only load micro-frontends when needed
- Caching: Implement effective caching strategies
- Code Splitting: Use Next.js's automatic code splitting
- Shared Dependencies: Carefully manage shared libraries to avoid duplicate code
Testing Micro-Frontends
Testing becomes more complex with micro-frontends. Consider these approaches:
- Unit Tests: Test individual components in isolation
- Integration Tests: Test how micro-frontends interact
- End-to-End Tests: Test the entire application flow
- Contract Tests: Verify that micro-frontends adhere to expected interfaces
Example of a component test using Jest and React Testing Library:
// ProductList.test.js
import { render, screen } from '@testing-library/react';
import ProductList from './ProductList';
describe('ProductList Component', () => {
it('renders the list of products', async () => {
// Mock data
const mockProducts = [
{ id: '1', name: 'Product 1', price: 9.99 },
{ id: '2', name: 'Product 2', price: 19.99 },
];
// Mock the fetch call
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
json: () => Promise.resolve(mockProducts)
})
);
render(<ProductList limit={2} />);
// Check if products are rendered
expect(await screen.findByText('Product 1')).toBeInTheDocument();
expect(await screen.findByText('Product 2')).toBeInTheDocument();
});
});
Deployment Strategies
With micro-frontends, you can use different deployment strategies:
- Independent Deployment: Each micro-frontend is deployed separately
- Coordinated Deployment: Deploy multiple micro-frontends together
- Blue-Green Deployment: Maintain two environments and switch between them
- Canary Releases: Roll out changes to a small subset of users first
For Next.js applications, you might deploy them on Vercel:
# Deploy the host application
cd host
vercel
# Deploy the micro-frontends
cd ../product-catalog
vercel
cd ../user-account
vercel
cd ../shopping-cart
vercel
Challenges and Solutions
Challenge 1: Consistent Styling
Problem: Maintaining consistent UI across micro-frontends.
Solution: Use a shared design system or component library.
// In your shared components
import { ThemeProvider } from 'styled-components';
import theme from './theme';
export function SharedThemeProvider({ children }) {
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
);
}
Challenge 2: Routing
Problem: Managing navigation between micro-frontends.
Solution: Use a centralized routing solution in the host app.
// host/components/AppRouter.js
import { useRouter } from 'next/router';
import RemoteComponent from './RemoteComponent';
const routeConfig = {
'/products': { scope: 'remote1', module: './ProductList' },
'/products/:id': { scope: 'remote1', module: './ProductDetail' },
'/account': { scope: 'remote2', module: './UserProfile' },
'/cart': { scope: 'remote3', module: './Cart' },
};
export default function AppRouter() {
const router = useRouter();
const { pathname, query } = router;
// Find the matching route
const matchedRoute = Object.keys(routeConfig).find(route => {
// Handle parameterized routes
if (route.includes(':')) {
const routeParts = route.split('/');
const pathnameParts = pathname.split('/');
if (routeParts.length !== pathnameParts.length) return false;
return routeParts.every((part, i) => {
if (part.startsWith(':')) return true;
return part === pathnameParts[i];
});
}
return pathname === route;
});
if (!matchedRoute) return <div>Page not found</div>;
const { scope, module } = routeConfig[matchedRoute];
return (
<RemoteComponent
scope={scope}
module={module}
fallback={<div>Loading...</div>}
{...query}
/>
);
}
Summary
Micro-frontends with Next.js offer a powerful approach to building scalable, maintainable frontend applications that can be developed independently by multiple teams. In this guide, we've covered:
- The concept of micro-frontends and their benefits
- Different approaches to implementing micro-frontends
- How to set up Module Federation with Next.js
- Real-world implementation examples
- State sharing between micro-frontends
- Testing and deployment strategies
- Common challenges and their solutions
This architecture isn't appropriate for every project—it adds complexity that may not be necessary for smaller applications. However, for larger projects with multiple teams, micro-frontends can significantly improve development velocity and maintainability.
Additional Resources
Exercises
- Set up a simple host application with one remote micro-frontend using Module Federation.
- Implement a shared authentication system between two micro-frontends.
- Create a design system that can be used across multiple micro-frontends.
- Implement a cross-micro-frontend shopping cart using custom events.
- Set up a CI/CD pipeline that can deploy micro-frontends independently.
By mastering these concepts, you'll be well-equipped to build scalable frontend applications using Next.js and micro-frontend architecture!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)