Building Scalable Enterprise Applications with Micro Frontends
A Practical Guide to Module Federation in Production
Modern enterprise applications face a familiar challenge: as teams grow and features multiply, monolithic frontends become unwieldy. Deployment bottlenecks emerge, teams step on each other's toes, and what started as a clean codebase turns into a coordination nightmare. Micro frontends offer a way out—but implementation details matter enormously.
This article explores how Module Federation enables genuinely independent frontend development while preserving the seamless user experience of a unified application. We'll examine real architectural decisions, practical tradeoffs, and patterns that work in production environments.
Understanding the Core Concept
Micro frontends extend microservices principles to the browser. Instead of building one massive frontend application, you construct independent modules—each owned by a specific team, potentially using different technology stacks, deployed on separate schedules. The magic happens at runtime when these pieces integrate into what users perceive as a single, cohesive application.
Several integration approaches exist: iframe embedding, Web Components, server-side composition, and runtime JavaScript composition. Each carries distinct tradeoffs around isolation, performance, and developer experience. Module Federation, introduced in Webpack 5, represents perhaps the most sophisticated approach to runtime composition—sharing code dynamically between independently built applications without the overhead of iframes or the ceremony of publishing to NPM.
How Module Federation Works
The fundamental insight behind Module Federation is that JavaScript bundles don't need to be self-contained islands. A "host" application can dynamically load modules from a "remote" application at runtime, as if they were local imports. The remote application exposes specific components through a manifest file (typically called remoteEntry.js), and the host consumes them through asynchronous imports.
Remote App Webpack Configuration
The remote application's Webpack configuration defines what it exposes to potential consumers:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin;
const path = require('path');
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: {
port: 3001, // Remote app runs on this port
},
output: {
publicPath: 'auto', // Required for dynamic federation
},
plugins: [
new ModuleFederationPlugin({
name: 'RemoteApp', // Internal name of the remote app
filename: 'remoteEntry.js', // Entry file others will load
exposes: {
'./RemoteInfo': './src/RemoteInfo.jsx', // Expose this module
'./Dashboard': './src/components/Dashboard.jsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
Host App Webpack Configuration
The host application configures where to find remote modules:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin;
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: {
port: 3000, // Host app runs on this port
},
output: {
publicPath: 'auto',
},
plugins: [
new ModuleFederationPlugin({
name: 'HostApp',
remotes: {
remoteApp: 'RemoteApp@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
};
Consuming Remote Modules in the Host
import React, { Suspense } from 'react';
// Dynamically import remote components
const RemoteInfo = React.lazy(() => import('remoteApp/RemoteInfo'));
const Dashboard = React.lazy(() => import('remoteApp/Dashboard'));
function App() {
return (
<div className="app-container">
<header>
<h1>Host Application</h1>
</header>
<main>
<Suspense fallback={<div className="loading">Loading Remote Info...</div>}>
<RemoteInfo />
</Suspense>
<Suspense fallback={<div className="loading">Loading Dashboard...</div>}>
<Dashboard />
</Suspense>
</main>
</div>
);
}
export default App;
Architectural Benefits
The practical advantages extend beyond code sharing:
- Independent deployment pipelines. Teams ship features without coordinating release schedules with every other team touching the application. This dramatically reduces the coordination overhead that slows large organizations.
- Technology flexibility. While sharing React across modules is common, teams can make different choices where appropriate. A data visualization team might prefer Svelte for performance-critical charts; a forms-heavy team might choose Angular. The architecture accommodates these decisions.
- Native integration. Unlike iframe-based approaches, federated modules integrate directly into the host's DOM. Styling, event handling, and state management work normally—no postMessage bridges or complicated CSS isolation schemes required.
- Reduced duplication. Shared dependency configuration prevents the bloat of loading multiple copies of common libraries. This matters enormously for user experience when applications include heavy frameworks.
The Tradeoffs
No architecture comes without costs. Understanding these tradeoffs helps teams make informed decisions:
- Webpack coupling. Module Federation is fundamentally a Webpack feature. While alternatives exist for Vite and Rollup, they're not native implementations and may lag behind in features or stability.
- Configuration complexity. Each application requires careful Webpack configuration. Shared dependencies must be explicitly declared and version-aligned. This setup cost is non-trivial and requires ongoing maintenance.
- Runtime failure modes. When a remote application is unavailable, the host application must handle that gracefully. Without proper error boundaries and fallback components, users see broken interfaces rather than degraded functionality.
- Version coordination. Shared libraries like React require tight version alignment. A remote accidentally deploying React 18 while the host uses React 17 can cause subtle, difficult-to-diagnose bugs.
Implementation Patterns That Work
Error Boundary for Graceful Failure Handling
Network failures, deployment mismatches, and remote application bugs can all cause dynamic imports to fail. A well-designed error boundary displays a meaningful message and offers retry functionality:
import React from 'react';
class RemoteErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Remote module failed to load:', error, errorInfo);
// Optionally send to error tracking service
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h3>Failed to load remote module</h3>
<p>The component is temporarily unavailable.</p>
<button onClick={this.handleRetry}>Try Again</button>
</div>
);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<RemoteErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<RemoteComponent />
</Suspense>
</RemoteErrorBoundary>
);
}
export default RemoteErrorBoundary;
Dynamic Remote Loading
For scenarios where remote URLs need to be determined at runtime:
// utils/loadRemote.js
const loadRemote = async (remoteName, remoteUrl) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = remoteUrl;
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
const container = window[remoteName];
if (container) {
container.init(__webpack_share_scopes__.default);
resolve(container);
} else {
reject(new Error(`Remote ${remoteName} not found`));
}
};
script.onerror = () => {
reject(new Error(`Failed to load remote from ${remoteUrl}`));
};
document.head.appendChild(script);
});
};
// Usage
const loadDashboard = async () => {
const container = await loadRemote('RemoteApp', 'http://localhost:3001/remoteEntry.js');
const factory = await container.get('./Dashboard');
const Module = factory();
return Module;
};
Security and Access Control
Enterprise applications require robust access control. Role-based access control (RBAC) provides a manageable approach: permissions attach to roles, and roles attach to users based on job function.
Checking User Permissions from OKTA Token
// auth/permissions.js
// Role-to-permissions mapping
const rolePermissions = {
admin: ['read', 'write', 'delete', 'manage_users'],
manager: ['read', 'write', 'approve_requests'],
sales_rep: ['read', 'write', 'create_quotes'],
viewer: ['read'],
};
// Extract claims from OKTA token
export const getUserClaims = () => {
const tokenStorage = localStorage.getItem('okta-token-storage');
if (!tokenStorage) {
return null;
}
try {
const parsed = JSON.parse(tokenStorage);
return parsed.accessToken?.claims || null;
} catch (error) {
console.error('Failed to parse token:', error);
return null;
}
};
// Get user roles from claims
export const getUserRoles = () => {
const claims = getUserClaims();
return claims?.roles || [];
};
// Check if user has a specific permission
export const hasPermission = (requiredPermission) => {
const userRoles = getUserRoles();
return userRoles.some(role =>
rolePermissions[role]?.includes(requiredPermission)
);
};
// Check if user has any of the required permissions
export const hasAnyPermission = (permissions) => {
return permissions.some(permission => hasPermission(permission));
};
// Check if user has all required permissions
export const hasAllPermissions = (permissions) => {
return permissions.every(permission => hasPermission(permission));
};
Protected Route Component
import React from 'react';
import { Navigate } from 'react-router-dom';
import { hasPermission, hasAnyPermission } from './auth/permissions';
const ProtectedRoute = ({
children,
requiredPermission,
requiredPermissions,
requireAll = false,
fallbackPath = '/unauthorized'
}) => {
let isAuthorized = false;
if (requiredPermission) {
isAuthorized = hasPermission(requiredPermission);
} else if (requiredPermissions) {
isAuthorized = requireAll
? hasAllPermissions(requiredPermissions)
: hasAnyPermission(requiredPermissions);
}
if (!isAuthorized) {
return <Navigate to={fallbackPath} replace />;
}
return children;
};
// Usage
function AppRoutes() {
return (
<Routes>
<Route path="/dashboard" element={
<ProtectedRoute requiredPermission="read">
<Dashboard />
</ProtectedRoute>
} />
<Route path="/admin" element={
<ProtectedRoute requiredPermissions={['manage_users', 'delete']} requireAll>
<AdminPanel />
</ProtectedRoute>
} />
</Routes>
);
}
export default ProtectedRoute;
API Integration Best Practices
Frontend applications live and die by their API integrations. Several patterns consistently improve reliability and maintainability.
Centralized Axios Configuration with Interceptors
// api/apiClient.js
import axios from 'axios';
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
paramsSerializer: {
indexes: null, // Format array indexes in params
},
});
// Request interceptor - inject auth token
apiClient.interceptors.request.use(
(config) => {
const tokenStorage = localStorage.getItem('okta-token-storage');
if (tokenStorage) {
const { accessToken } = JSON.parse(tokenStorage);
if (accessToken?.accessToken) {
config.headers.Authorization = `Bearer ${accessToken.accessToken}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - handle errors globally
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const { response } = error;
if (response?.status === 401) {
// Token expired - redirect to login
localStorage.removeItem('okta-token-storage');
window.location.href = '/login';
}
if (response?.status === 403) {
// Forbidden - redirect to unauthorized page
window.location.href = '/unauthorized';
}
if (response?.status >= 500) {
// Server error - show notification
console.error('Server error:', response.data);
}
return Promise.reject(error);
}
);
export default apiClient;
Service Layer Pattern
// services/UserService.js
import apiClient from '../api/apiClient';
const UserService = {
getAll: async (params = {}) => {
const response = await apiClient.get('/users', { params });
return response.data;
},
getById: async (id) => {
const response = await apiClient.get(`/users/${id}`);
return response.data;
},
create: async (userData) => {
const response = await apiClient.post('/users', userData);
return response.data;
},
update: async (id, userData) => {
const response = await apiClient.put(`/users/${id}`, userData);
return response.data;
},
delete: async (id) => {
const response = await apiClient.delete(`/users/${id}`);
return response.data;
},
};
export default UserService;
Custom Hook with Loading and Error States
// hooks/useApi.js
import { useState, useEffect, useCallback } from 'react';
const useApi = (apiFunction, immediate = true) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(async (...params) => {
setIsLoading(true);
setIsError(false);
setError(null);
try {
const result = await apiFunction(...params);
setData(result);
return result;
} catch (err) {
setIsError(true);
setError(err.response?.data?.message || err.message);
throw err;
} finally {
setIsLoading(false);
}
}, [apiFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { data, isLoading, isError, error, execute, setData };
};
// Usage
function UserList() {
const { data: users, isLoading, isError, error } = useApi(UserService.getAll);
if (isLoading) return <div className="spinner">Loading...</div>;
if (isError) return <div className="error">Error: {error}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default useApi;
Parallel API Requests
// Fetch multiple independent endpoints simultaneously
const fetchDashboardData = async () => {
const [users, products, orders, analytics] = await Promise.all([
apiClient.get('/users'),
apiClient.get('/products'),
apiClient.get('/orders'),
apiClient.get('/analytics/summary'),
]);
return {
users: users.data,
products: products.data,
orders: orders.data,
analytics: analytics.data,
};
};
// Usage in component
function Dashboard() {
const [dashboardData, setDashboardData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadData = async () => {
try {
const data = await fetchDashboardData();
setDashboardData(data);
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setIsLoading(false);
}
};
loadData();
}, []);
if (isLoading) return <LoadingSpinner />;
return (
<div className="dashboard">
<UserStats users={dashboardData.users} />
<ProductGrid products={dashboardData.products} />
<OrderTable orders={dashboardData.orders} />
<AnalyticsChart data={dashboardData.analytics} />
</div>
);
}
When to Use This Architecture
Micro frontends with Module Federation solve organizational problems as much as technical ones. They shine when multiple teams need to contribute to a single user-facing application without stepping on each other's deployment schedules. They make sense when different parts of an application have genuinely different technical requirements or change at different rates.
They're overkill for small teams building straightforward applications. The setup complexity, version coordination requirements, and runtime failure modes add overhead that only pays off at scale. A team of five building a CRUD application should probably just build a monolithic frontend and move on.
The decision ultimately comes down to whether deployment independence and team autonomy justify the architectural investment. For large organizations with multiple frontend teams, the answer is often yes. For everyone else, simpler approaches usually serve better.
Conclusion
Module Federation represents a genuine advancement in how we build large-scale frontend applications. It enables the kind of team autonomy that microservices brought to backend development, without sacrificing the integrated user experience that single-page applications provide. But it's a tool, not a silver bullet—one that works best when applied thoughtfully to problems it's suited to solve.
The patterns described here have proven effective in production environments serving real users. They balance the idealism of perfect decoupling with the pragmatism of systems that must actually work. Whether you're evaluating micro frontends for a new project or improving an existing implementation, these principles should help guide decisions that your future self will appreciate.
This article is based on architectural patterns implemented in enterprise workforce management applications.
Reference Implementation: The complete working demo for the Module Federation examples in this article is available at github.com/mshoaibiqbal/micro-frontends-demo. Clone the repository and follow the README instructions to see micro frontends in action.