Micro-Frontends: Best Practices for Scalable and Maintainable Applications
Your monolithic frontend is starting to hurt. Deployments take forever, five teams are stepping on each other's toes, and a bug fix in the checkout flow somehow breaks the navigation. Sound familiar?…
Micro-Frontends: Best Practices for Scalable and Maintainable Applications
Your monolithic frontend is starting to hurt. Deployments take forever, five teams are stepping on each other's toes, and a bug fix in the checkout flow somehow breaks the navigation. Sound familiar? That's the problem micro-frontends solve — and why teams building large, complex apps are adopting them fast.
Why Micro-Frontends Matter
The backend world figured this out years ago with microservices. Independent teams, independent deployments, independent scaling. Micro-frontends bring the same philosophy to the UI layer.
The core idea: split your frontend application into smaller, independently deployable pieces owned by separate teams. The product team owns the product catalog. The checkout team owns the cart and payment flow. The auth team owns login. Each piece can be built, tested, and deployed without coordinating with everyone else.
The payoff is real:
But it's not free. You're trading one set of problems for another. Let's talk about how to implement this well.
How Micro-Frontends Actually Work
There are three main integration approaches, each with different trade-offs.
1. Build-Time Integration
Each micro-frontend is published as an npm package, and the shell app imports them at build time.
{
"dependencies": {
"@myapp/product-catalog": "^2.1.0",
"@myapp/checkout": "^1.4.0",
"@myapp/user-profile": "^3.0.1"
}
}This is the simplest approach, but it defeats the purpose. You still need to rebuild and redeploy the shell every time a micro-frontend changes. Avoid this for anything serious.
2. Runtime Integration via iframes
Old school, but genuinely useful in specific cases. Each micro-frontend lives in its own iframe. Strong isolation, works across any tech stack, no CSS or JS conflicts.
The downsides are real though — poor UX for shared state, accessibility headaches, and performance costs. Use iframes when you're integrating a legacy app or a third-party tool you don't control.
3. Runtime Integration via JavaScript (Module Federation)
This is the modern sweet spot. Webpack 5's Module Federation lets micro-frontends expose components that the shell app loads at runtime — no rebuild required.
Here's a basic setup. In your checkout micro-frontend's webpack.config.js:
const { ModuleFederationPlugin } = require('webpack').container;module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutFlow': './src/CheckoutFlow',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
And in your shell app:
const { ModuleFederationPlugin } = require('webpack').container;module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: 'checkout@https://checkout.myapp.com/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
Now the shell can lazy-load the checkout flow at runtime:
import React, { Suspense, lazy } from 'react';const CheckoutFlow = lazy(() => import('checkout/CheckoutFlow'));
function App() {
return (
<Suspense fallback={<div>Loading checkout...</div>}>
<CheckoutFlow />
</Suspense>
);
}
The checkout team deploys their update to checkout.myapp.com and the shell picks it up automatically. No shell redeploy needed.
Communication Between Micro-Frontends
This is where things get tricky. Micro-frontends need to share some state — the logged-in user, the cart count, the active theme. But you don't want tight coupling between them.
Use a shared event bus. Browser custom events work well and require zero shared libraries:
// Checkout micro-frontend dispatches an event
window.dispatchEvent(new CustomEvent('cart:updated', {
detail: { itemCount: 3, total: 89.99 }
}));// Header micro-frontend listens for it
window.addEventListener('cart:updated', (event) => {
updateCartBadge(event.detail.itemCount);
});
For more complex state sharing, consider a lightweight pub/sub utility or a shared state store exposed through Module Federation. Just be careful — the more state you share, the more coupled your micro-frontends become.
A shared auth token is fine to keep in localStorage or a cookie. All micro-frontends read it directly. No need to pass it through events.
Practical Tips That Will Save You Pain
Agree on a design system upfront. Nothing looks worse than three different button styles on the same page because three teams picked different component libraries. Extract your shared UI into a versioned package that everyone depends on.
# Your shared design system
npm install @myapp/design-systemHandle loading and error states gracefully. A micro-frontend that fails to load shouldn't crash the whole page. Wrap remote components in error boundaries:
class MicroFrontendErrorBoundary extends React.Component {
state = { hasError: false }; static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>This section is temporarily unavailable.</div>;
}
return this.props.children;
}
}
// Usage
<MicroFrontendErrorBoundary>
<CheckoutFlow />
</MicroFrontendErrorBoundary>
Pin shared dependency versions strictly. If the shell uses React 18.2 and a micro-frontend ships React 18.0, you might end up with two React instances on the page. The singleton: true flag in Module Federation helps, but you still need version alignment across teams.
Set up a contract testing strategy. Each micro-frontend exposes an interface — props it accepts, events it emits. Use something like Pact or even a simple JSON schema to document and test these contracts. Catching interface breakage in CI is way better than catching it in production.
Avoid over-splitting. Not every component needs to be a micro-frontend. A good rule of thumb: if two features are always deployed together, they don't need to be separate micro-frontends. Split along team and deployment boundaries, not component boundaries.
Performance Considerations
Micro-frontends can hurt your bundle size if you're not careful. Each remote potentially ships its own copy of shared libraries.
shared config in Module Federation to deduplicate common librariesActionable Next Steps
If you're building a new large-scale frontend or your current monolith is becoming a bottleneck, here's how to get started:
Micro-frontends aren't a silver bullet. They add real operational complexity. But for large teams working on large apps, the autonomy and scalability they enable is worth it. Start small, validate the approach, and expand from there.