Skip to main content

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:

  1. Server-side rendering and static site generation: Excellent for performance and SEO
  2. Built-in routing system: Simplifies navigation between micro-frontends
  3. API routes: Facilitates backend communication
  4. Module Federation support: Through webpack plugins
  5. 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:

bash
npm install @module-federation/nextjs-mf

1. Configure the Host Application

Create or modify your next.config.js file in your host application:

javascript
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:

javascript
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:

jsx
// 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

jsx
// 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

jsx
// 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

jsx
// 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:

javascript
// 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:

jsx
// 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

  1. Lazy Loading: Only load micro-frontends when needed
  2. Caching: Implement effective caching strategies
  3. Code Splitting: Use Next.js's automatic code splitting
  4. Shared Dependencies: Carefully manage shared libraries to avoid duplicate code

Testing Micro-Frontends

Testing becomes more complex with micro-frontends. Consider these approaches:

  1. Unit Tests: Test individual components in isolation
  2. Integration Tests: Test how micro-frontends interact
  3. End-to-End Tests: Test the entire application flow
  4. Contract Tests: Verify that micro-frontends adhere to expected interfaces

Example of a component test using Jest and React Testing Library:

jsx
// 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:

  1. Independent Deployment: Each micro-frontend is deployed separately
  2. Coordinated Deployment: Deploy multiple micro-frontends together
  3. Blue-Green Deployment: Maintain two environments and switch between them
  4. Canary Releases: Roll out changes to a small subset of users first

For Next.js applications, you might deploy them on Vercel:

bash
# 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.

jsx
// 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.

jsx
// 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

  1. Set up a simple host application with one remote micro-frontend using Module Federation.
  2. Implement a shared authentication system between two micro-frontends.
  3. Create a design system that can be used across multiple micro-frontends.
  4. Implement a cross-micro-frontend shopping cart using custom events.
  5. 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! :)