Los micro-frontends son a Module Federation lo que los microservicios son a REST: una forma de descomponer una monolito frontend en pedazos desplegables independientemente. Llevo dos años manteniendo un micro-frontend en producción con 4 equipos distintos y quiero compartir cómo lo estructuramos en AWS, los errores que cometimos, y cuándo realmente vale la pena.
Qué resuelve realmente
flowchart TB
subgraph Monolito[Monolito frontend]
M1[Equipo A trabaja en header]
M2[Equipo B trabaja en checkout]
M3[Equipo C trabaja en perfil]
M4[Un solo deploy]
M5[Un solo build]
M6[Todos comparten dependencias]
end
subgraph MF[Micro-frontends]
F1[Equipo A: header-app<br/>deploy independiente]
F2[Equipo B: checkout-app<br/>deploy independiente]
F3[Equipo C: profile-app<br/>deploy independiente]
F4[Shell app compone todo]
end
style MF fill:#f39c12,color:#fff
Antes de seguir: si tu equipo son menos de 20 personas, probablemente no necesitas esto. Los micro-frontends tienen costos reales (complejidad, orchestración, versionado de dependencias). Solo valen la pena cuando:
- Tienes 4+ equipos trabajando en la misma app.
- Los equipos necesitan ciclos de deploy distintos.
- Hay dominios de negocio claramente separados.
La arquitectura en AWS
flowchart LR
User[Usuario] --> CF[CloudFront]
CF -->|/| Shell[Shell App<br/>S3 bucket-shell]
CF -->|/checkout/*| CheckoutBundle[Checkout Remote<br/>S3 bucket-checkout]
CF -->|/profile/*| ProfileBundle[Profile Remote<br/>S3 bucket-profile]
CF -->|/products/*| ProductsBundle[Products Remote<br/>S3 bucket-products]
Shell -.dynamically loads.-> CheckoutBundle
Shell -.dynamically loads.-> ProfileBundle
Shell -.dynamically loads.-> ProductsBundle
style CF fill:#146eb4,color:#fff
style Shell fill:#e74c3c,color:#fff
Cada micro-frontend vive en su propio bucket S3, con su propio pipeline. CloudFront los une bajo un mismo dominio.
Configurando Module Federation en Webpack
El shell app es el contenedor:
// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
const deps = require('./package.json').dependencies;
module.exports = {
entry: './src/index.ts',
mode: 'production',
output: {
publicPath: 'auto',
clean: true,
filename: '[name].[contenthash].js',
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
checkout: `checkout@${process.env.CHECKOUT_URL}/remoteEntry.js`,
profile: `profile@${process.env.PROFILE_URL}/remoteEntry.js`,
products: `products@${process.env.PRODUCTS_URL}/remoteEntry.js`,
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
strictVersion: false,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
'@tanstack/react-query': {
singleton: true,
requiredVersion: deps['@tanstack/react-query'],
},
},
}),
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],
};
Un remote (por ejemplo checkout):
// checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
entry: './src/index.ts',
mode: 'production',
output: {
publicPath: 'auto',
filename: '[name].[contenthash].js',
uniqueName: 'checkout',
},
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
'./CheckoutApp': './src/App',
'./CartWidget': './src/components/CartWidget',
'./useCart': './src/hooks/useCart',
},
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'react-router-dom': {
singleton: true,
requiredVersion: deps['react-router-dom'],
},
},
}),
],
};
Carga dinámica en el shell
El shell app carga los remotes dinámicamente con lazy + error boundaries:
// shell/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { Layout } from './components/Layout';
import { Loading } from './components/Loading';
import { ErrorFallback } from './components/ErrorFallback';
const CheckoutApp = lazy(() => import('checkout/CheckoutApp'));
const ProfileApp = lazy(() => import('profile/ProfileApp'));
const ProductsApp = lazy(() => import('products/ProductsApp'));
export function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/checkout/*"
element={
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Loading />}>
<CheckoutApp />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/profile/*"
element={
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Loading />}>
<ProfileApp />
</Suspense>
</ErrorBoundary>
}
/>
<Route
path="/products/*"
element={
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Loading />}>
<ProductsApp />
</Suspense>
</ErrorBoundary>
}
/>
</Routes>
</Layout>
</BrowserRouter>
);
}
El ErrorBoundary alrededor de cada remote es crítico: si un equipo rompe su deploy, solo su sección se rompe, no toda la app.
Compartir estado entre micro-frontends
Esto es lo más complicado. Hay varias estrategias:
Estrategia 1: Shared package exporteable
// shared/src/state/auth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
token: string | null;
setUser: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
setUser: (user) => set({ user, token: user.token }),
logout: () => set({ user: null, token: null }),
}),
{ name: 'auth-storage' }
)
);
Este store se exporta y se consume en todos los remotes. Como zustand está en shared: { singleton: true }, es la misma instancia en toda la app.
Estrategia 2: Custom events
// shared/src/event-bus.ts
type EventMap = {
'cart:item-added': { productId: string; quantity: number };
'cart:cleared': void;
'user:logged-in': { userId: string };
'user:logged-out': void;
};
class TypedEventBus {
private target = new EventTarget();
emit<K extends keyof EventMap>(type: K, detail: EventMap[K]) {
this.target.dispatchEvent(new CustomEvent(type, { detail }));
}
on<K extends keyof EventMap>(
type: K,
handler: (detail: EventMap[K]) => void
): () => void {
const wrapper = (e: Event) => handler((e as CustomEvent).detail);
this.target.addEventListener(type, wrapper);
return () => this.target.removeEventListener(type, wrapper);
}
}
export const eventBus = new TypedEventBus();
Uso:
// En checkout
import { eventBus } from 'shared/event-bus';
function AddToCartButton({ product }) {
return (
<button onClick={() => eventBus.emit('cart:item-added', {
productId: product.id,
quantity: 1,
})}>
Agregar
</button>
);
}
// En shell o cualquier otro remote
import { useEffect, useState } from 'react';
import { eventBus } from 'shared/event-bus';
function CartBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
return eventBus.on('cart:item-added', () => {
setCount(c => c + 1);
});
}, []);
return <span>🛒 {count}</span>;
}
Stack CDK completo
Cada micro-frontend tiene su propio stack. Aquí el patrón reutilizable:
// infra/lib/microfrontend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import { Construct } from 'constructs';
export interface MicrofrontendStackProps extends cdk.StackProps {
name: string;
sourcePath: string;
subdomain: string;
hostedZone: cdk.aws_route53.IHostedZone;
}
export class MicrofrontendStack extends cdk.Stack {
public readonly distribution: cloudfront.Distribution;
public readonly bucket: s3.Bucket;
public readonly url: string;
constructor(scope: Construct, id: string, props: MicrofrontendStackProps) {
super(scope, id, props);
this.bucket = new s3.Bucket(this, 'Bucket', {
bucketName: `mfe-${props.name}-${this.account}`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
cors: [
{
allowedOrigins: ['*'],
allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.HEAD],
allowedHeaders: ['*'],
maxAge: 3600,
},
],
});
// CRÍTICO: CORS permisivo para que el shell pueda cargar remoteEntry.js
// desde un dominio diferente
this.distribution = new cloudfront.Distribution(this, 'Distribution', {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: new cloudfront.CachePolicy(this, 'CachePolicy', {
// remoteEntry.js NO debe cachearse agresivamente
defaultTtl: cdk.Duration.minutes(5),
minTtl: cdk.Duration.seconds(0),
maxTtl: cdk.Duration.hours(1),
headerBehavior: cloudfront.CacheHeaderBehavior.allowList(
'Origin',
'Access-Control-Request-Method',
'Access-Control-Request-Headers'
),
queryStringBehavior: cloudfront.CacheQueryStringBehavior.none(),
enableAcceptEncodingBrotli: true,
enableAcceptEncodingGzip: true,
}),
responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS,
},
additionalBehaviors: {
// Chunks con hash: cache largo
'*.chunk.js': {
origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS,
},
},
});
new s3deploy.BucketDeployment(this, 'Deployment', {
sources: [s3deploy.Source.asset(props.sourcePath)],
destinationBucket: this.bucket,
distribution: this.distribution,
distributionPaths: ['/remoteEntry.js', '/index.html'],
prune: true,
memoryLimit: 512,
});
this.url = `https://${this.distribution.distributionDomainName}`;
}
}
El shell stack apunta a los URLs de los remotes:
// infra/app.ts
const app = new cdk.App();
const checkoutMfe = new MicrofrontendStack(app, 'CheckoutMfe', {
name: 'checkout',
sourcePath: '../checkout/dist',
// ...
});
const profileMfe = new MicrofrontendStack(app, 'ProfileMfe', {
name: 'profile',
sourcePath: '../profile/dist',
// ...
});
const shellStack = new ShellStack(app, 'ShellStack', {
remoteUrls: {
checkout: checkoutMfe.url,
profile: profileMfe.url,
},
});
shellStack.addDependency(checkoutMfe);
shellStack.addDependency(profileMfe);
Pipeline de CI/CD por equipo
Cada equipo tiene su pipeline:
# .github/workflows/deploy-checkout.yml
name: Deploy Checkout MFE
on:
push:
branches: [main]
paths:
- 'checkout/**'
- 'shared/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Build shared
run: |
cd shared
npm ci
npm run build
- name: Build checkout
run: |
cd checkout
npm ci
npm run build
- name: Run tests
run: cd checkout && npm test
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubDeployer
aws-region: us-east-1
- name: Deploy
run: |
cd infra
npm ci
npx cdk deploy CheckoutMfe --require-approval never
Versionado de dependencias compartidas
El mayor dolor de cabeza. Si el shell tiene React 18.2 y un remote tiene React 19, las cosas explotan. Estrategias:
1. Pin estricto de versions en shell
El shell es el source of truth. Todos los remotes deben usar las mismas versiones de dependencias críticas.
{
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.26.0"
}
}
2. Automatizar el check
// tools/verify-dependencies.ts
import fs from 'fs';
import path from 'path';
const shellDeps = JSON.parse(
fs.readFileSync('shell/package.json', 'utf-8')
).dependencies;
const critical = ['react', 'react-dom', 'react-router-dom', 'zustand'];
const apps = ['checkout', 'profile', 'products'];
const errors: string[] = [];
for (const app of apps) {
const pkg = JSON.parse(
fs.readFileSync(path.join(app, 'package.json'), 'utf-8')
);
for (const dep of critical) {
if (pkg.dependencies[dep] !== shellDeps[dep]) {
errors.push(`${app}: ${dep} es ${pkg.dependencies[dep]}, shell tiene ${shellDeps[dep]}`);
}
}
}
if (errors.length > 0) {
console.error('Dependencias críticas inconsistentes:');
errors.forEach(e => console.error(' -', e));
process.exit(1);
}
Este script corre en CI y bloquea merges si hay drift.
Monitoreo específico para MFE
CloudWatch custom metrics para detectar cuando un remote falla:
// shell/src/monitoring/remote-monitor.ts
export function trackRemoteLoad(remoteName: string, duration: number, success: boolean) {
fetch('/api/metrics/remote-load', {
method: 'POST',
body: JSON.stringify({
remote: remoteName,
duration,
success,
timestamp: Date.now(),
userAgent: navigator.userAgent,
}),
keepalive: true,
});
}
Lambda que recibe:
import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
const cw = new CloudWatchClient({});
export const handler = async (event: any) => {
const body = JSON.parse(event.body);
await cw.send(
new PutMetricDataCommand({
Namespace: 'MFE',
MetricData: [
{
MetricName: 'RemoteLoadDuration',
Value: body.duration,
Unit: 'Milliseconds',
Dimensions: [
{ Name: 'Remote', Value: body.remote },
{ Name: 'Success', Value: String(body.success) },
],
},
{
MetricName: 'RemoteLoadCount',
Value: 1,
Unit: 'Count',
Dimensions: [
{ Name: 'Remote', Value: body.remote },
{ Name: 'Success', Value: String(body.success) },
],
},
],
})
);
return { statusCode: 200 };
};
Con esto, una alarma de CloudWatch puede avisar cuando un remote empieza a fallar.
Lo que NO funcionó
1. Versiones distintas de React por remote.
Intentamos que cada equipo usara su versión. Era un hack con parches manuales. Rompió en producción múltiples veces. Solución: single version policy.
2. Shared state con Redux store global.
Compartir un store Redux entre MFEs es teóricamente elegante, en la práctica genera acoplamiento y bugs. Mejor event-driven o props.
3. Routing dinámico desde remotes.
Los remotes registrando rutas en runtime sonaba flexible pero hace que el shell no sepa qué rutas existen. Decidimos: cada remote tiene su prefix, el shell controla el routing de alto nivel.
Cuándo esto NO vale la pena
- Equipo de menos de 15 personas.
- No hay problemas reales de deploy.
- Los "dominios" no están claros o cambian seguido.
- No tienes presupuesto de plataforma.
Cierre
Los micro-frontends con Module Federation son una herramienta potente para escalar equipos frontend, pero con costos reales. El setup inicial es complejo, el mantenimiento requiere disciplina, y el versionado de dependencias es un dolor permanente. Si te sientas a evaluar honestamente tu situación y aún ves beneficios, este es el patrón.
En el próximo artículo vamos al edge: Lambda@Edge para A/B testing de frontend con routing en el edge.
Top comments (0)