Scalable Microfrontends: Architecting a Decoupled UI with Module Federation

I am a full stack developer, a Machine Learning enthusiast & a Flutter Developer. Also, I am a budding Entrepreneur!
I am a student of Life and believe in building tech for people.
I love contributing to open-source projects and use my knowledge to develop a software solution for general problems arising in the community.
Have you ever worked on a frontend codebase so large that you feared deploying a CSS change to the Settings page because it might somehow break the Checkout flow? Or perhaps you've struggled to align three different teams working on the same React repo, constantly stepping on each other's toes with merge conflicts?
The "Frontend Monolith" is a real struggle. The solution? Microfrontends (MFEs).
In this guide, we won't just build a "Hello World" MFE. We are going to architect a production-grade system using React, Vite, and Module Federation. We will implement a specific, highly scalable pattern: Separating the UI Layer (the MFE) from the Data Layer (a Headless NPM package).
What are Microfrontends?
Microfrontends apply the "Microservices" concept to the frontend. Instead of one giant application, you break your website into smaller, independent applications (e.g., User Profile, Product Catalog, Cart).
The Problems They Solve:
Independent Deployment: Team A can deploy the "User Profile" MFE without waiting for Team B to finish the "Checkout" MFE.
Tech Stack Agnostic: Theoretically, one MFE could be React while another is Vue (though sticking to one framework is usually better for performance).
Scalability: Teams can work in parallel on separate repositories.
Incremental Upgrades: You can rewrite legacy code piece by piece.
The Architecture: The "Split-Layer" Pattern
Most MFE tutorials shove API calls, logic, and UI into one bundle. We are going to do better. We will split our MFE into two distinct layers managed in separate repositories:
The Data Layer (Headless): A TypeScript NPM package containing API clients, models, and business logic. It knows nothing about React or the DOM.
The UI Layer (The MFE): A React application that consumes the Data Layer to render the interface. It is exposed to the "Host" via Module Federation.
The High-Level Diagram

Part 1: The Headless Data Layer
This layer is the "brain." It handles data fetching and transformation. It is published as a private NPM package (e.g., to GitHub Packages) so both the MFE and potentially the Host can use it with type safety.
1. The Configuration Pattern (Inversion of Control)
The Data Layer shouldn't hardcode URLs or Auth tokens. It should ask for them. We use a configuration function.
// data-layer/src/config.ts
export interface DataLayerConfig {
// The Host decides the API location
apiBaseUrl: string;
// The Host provides a way to get the CURRENT token
getToken: () => string | null | undefined | Promise<string | null | undefined>;
}
let internalConfig: DataLayerConfig | null = null;
export const configureDataLayer = (config: DataLayerConfig) => {
if (!config.apiBaseUrl || !config.getToken) {
throw new Error("Invalid Data Layer Configuration");
}
internalConfig = config;
};
export const getConfig = () => internalConfig;
2. The Intelligent API Client
We create an Axios instance that "pulls" the token dynamically before every request. This handles token rotation automatically!
// data-layer/src/api/apiClient.ts
import axios from 'axios';
import { getConfig } from '../config';
// We defer instance creation until we have the Base URL
export const getApiClient = () => {
const config = getConfig();
if (!config) throw new Error("Data Layer not configured!");
const instance = axios.create({
baseURL: config.apiBaseUrl,
});
// Request Interceptor: The Secret Sauce
instance.interceptors.request.use(async (reqConfig) => {
// Call the function provided by the HOST to get the latest token
const token = await Promise.resolve(config.getToken());
if (token) {
reqConfig.headers.Authorization = `Bearer ${token}`;
}
return reqConfig;
});
return instance;
};
Part 2: The UI Layer (React MFE)
We build this using Vite, which offers a fantastic plugin for Module Federation: @originjs/vite-plugin-federation.
1. Vite Configuration
We expose a bootstrap file. We don't just expose a component because we need to handle initialization (configuring the data layer) before rendering.
// ui-mfe/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: 'my_app_ui_mfe',
filename: 'remoteEntry.js',
exposes: {
// We expose a mount function, not just a component
'./bootstrap': './src/bootstrap.tsx',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'react-router-dom': { singleton: true }
},
}),
],
build: {
target: 'esnext', // Required for top-level await support in MF
},
});
2. Styling Isolation
To prevent your MFE's CSS from breaking the Host app (and vice versa), use Tailwind CSS with a prefix.
// ui-mfe/tailwind.config.js
export default {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
prefix: 'mfe-user-', // All classes will be .mfe-user-bg-red-500
// ...
}
3. The Bootstrap Entry Point
This is the interface between the Host and the MFE. It accepts props, configures the Data Layer, and mounts the React app.
// ui-mfe/src/bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { createHostedRouter } from './routes';
import { configureDataLayer } from '@your-org/my-app-data-layer';
// The Contract: What the Host must provide
export interface MountProps {
config: {
apiBaseUrl: string;
getAuthToken: () => string | Promise<string | null>;
};
hostCallbacks?: {
onNavigateRequest?: (path: string) => void;
onHostEvent?: (event: string, payload: any) => void;
};
basename?: string;
}
const mount = (el: Element, props: MountProps) => {
// 1. Configure the internal Data Layer with Host's values
configureDataLayer({
apiBaseUrl: props.config.apiBaseUrl,
getToken: props.config.getAuthToken,
});
// 2. Initialize Router (MemoryRouter is best for MFEs)
const router = createHostedRouter(props.basename);
// 3. Render
const root = ReactDOM.createRoot(el);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
// 4. Return cleanup function
return {
unmount: () => root.unmount()
};
};
export { mount };
Part 3: The Integration (The Host App)
The Host is the orchestrator. It provides the "Environment" (Auth, URL) and the "Container" (DOM Element).
1. Module Federation Config (Host)
Tell the host where to find the MFE.
// host/vite.config.ts (or webpack.config.js)
federation({
name: 'host_app',
remotes: {
// The URL can be dynamic in production!
my_app_ui_mfe: 'http://localhost:3001/assets/remoteEntry.js',
},
shared: { react: { singleton: true }, ... }
})
2. The MFE Loader Component
This component handles the dynamic import and the lifecycle of the MFE.
// host/src/components/MfeLoader.tsx
import React, { useEffect, useRef } from 'react';
// Helper to load the module dynamically
// In a real app, wrap this in React.Suspense
const MfeLoader = ({ mountProps }) => {
const ref = useRef(null);
useEffect(() => {
let mfeInstance;
const loadMfe = async () => {
// Dynamic import matches the 'remotes' key and 'exposes' key
const module = await import('my_app_ui_mfe/bootstrap');
if (ref.current) {
// Mount the MFE and pass the PROPS
mfeInstance = module.mount(ref.current, mountProps);
}
};
loadMfe();
return () => {
// Cleanup when Host unmounts this component
mfeInstance?.unmount();
};
}, []); // Re-run if mountProps change deeply? Use caution.
return <div ref={ref} />;
};
export default MfeLoader;
3. Using the MFE in the Host
This is where the magic happens. The Host passes its own Auth logic into the MFE.
// host/src/App.tsx
import React, { useCallback } from 'react';
import MfeLoader from './components/MfeLoader';
import { useAuth } from './hooks/useAuth'; // Host's Auth hook
function App() {
const { getToken, logout } = useAuth();
// The MFE calls this function before EVERY API request.
// This ensures the MFE always uses the freshest token.
const provideTokenToMfe = useCallback(async () => {
return await getToken();
}, [getToken]);
const handleMfeEvent = (event, payload) => {
if (event === 'authExpired') {
logout(); // Host handles the logout!
}
};
const mfeProps = {
config: {
apiBaseUrl: 'https://api.production.com/v1',
getAuthToken: provideTokenToMfe, // Pass the function reference
},
hostCallbacks: {
onHostEvent: handleMfeEvent
}
};
return (
<div className="host-layout">
<h1>My Super App</h1>
<div className="mfe-container">
<MfeLoader mountProps={mfeProps} />
</div>
</div>
);
}
Handling Errors & Communication
Standardized Errors
Your Data Layer should intercept 4xx/5xx errors and reject with a standardized DataLayerError object.
MFE Responsibility: Catch
DataLayerError. If it's a 401, emit an event to the host. If it's a 400, show a validation error in the UI.Host Responsibility: Listen for
authExpiredevents and handle redirects.
The Fallback (Iframe)
If you ever need to run this MFE in an Iframe (e.g., legacy integration), Module Federation won't work.
Host: Listen to
window.postMessage.MFE: In your communication layer, check if
window.top!== window.self. If so, sendpostMessageinstead of calling thehostCallbacksprop.
Conclusion
By separating the Data Layer (Headless, Type-safe, Configurable) from the UI Layer (Visuals, Module Federation), and orchestrating them via a Host App that controls Authentication, you achieve a highly scalable architecture.
Benefits of this approach:
Zero Auth Logic Duplication: The MFE doesn't implement login/refresh flows. It just asks the Host for a token.
Type Safety: Shared models via the Data Layer package.
Style Isolation: Tailwind prefixing prevents CSS nightmares.
Framework Agnostic Data: The Data Layer can be reused in a React Native app or even an Angular MFE.
Ready to break up your monolith? Start small, extract the data layer first, and happy coding! ✨



