Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Micro-frontends

Micro-frontends Overview

Estimated Time: 3 hours | Difficulty: Advanced | Prerequisites: Routing, Lazy Loading, Build Process
Micro-frontends extend the microservices concept to the frontend, allowing teams to build and deploy parts of a web application independently. The analogy works well: just as a backend might have separate services for auth, payments, and inventory that communicate via APIs, a micro-frontend architecture lets separate teams own separate sections of the UI — each with its own repo, build pipeline, and deployment schedule. A word of caution: Micro-frontends solve an organizational problem (team autonomy at scale), not a technical one. If you have a single team building a single product, micro-frontends add significant complexity for zero benefit. The sweet spot is organizations with 3+ teams that need to ship features independently without coordinating releases.
┌─────────────────────────────────────────────────────────────────────────┐
│                    Micro-frontend Architecture                           │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   ┌─────────────────────────────────────────────────────────────────┐   │
│   │                         Host (Shell)                             │   │
│   │   ┌─────────────────────────────────────────────────────────┐   │   │
│   │   │                    Navigation/Layout                     │   │   │
│   │   └─────────────────────────────────────────────────────────┘   │   │
│   │                                                                  │   │
│   │   ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐    │   │
│   │   │    Remote 1   │ │    Remote 2   │ │     Remote 3      │    │   │
│   │   │   Dashboard   │ │    Products   │ │      Admin        │    │   │
│   │   │   (Team A)    │ │   (Team B)    │ │     (Team C)      │    │   │
│   │   └───────────────┘ └───────────────┘ └───────────────────┘    │   │
│   └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│   Benefits:                                                              │
│   • Independent deployment            • Technology agnostic             │
│   • Team autonomy                     • Smaller, focused codebases      │
│   • Incremental upgrades              • Parallel development            │
│                                                                          │
│   Challenges:                                                            │
│   • Shared state management           • Consistent styling              │
│   • Performance overhead              • Complex debugging               │
│   • Version compatibility             • Integration testing             │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Module Federation Setup

Using @angular-architects/module-federation

# Create workspace with multiple apps
npx create-nx-workspace@latest my-mfe --preset=empty
cd my-mfe

# Or with Angular CLI
ng new my-mfe-workspace --create-application=false
cd my-mfe-workspace

# Add host (shell) application
ng generate application shell --routing --style=scss

# Add remote applications
ng generate application dashboard --routing --style=scss
ng generate application products --routing --style=scss
ng generate application admin --routing --style=scss

# Add module federation to each app
ng add @angular-architects/module-federation --project shell --type host --port 4200
ng add @angular-architects/module-federation --project dashboard --type remote --port 4201
ng add @angular-architects/module-federation --project products --type remote --port 4202
ng add @angular-architects/module-federation --project admin --type remote --port 4203

Host (Shell) Configuration

// projects/shell/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({
  remotes: {
    dashboard: 'http://localhost:4201/remoteEntry.js',
    products: 'http://localhost:4202/remoteEntry.js',
    admin: 'http://localhost:4203/remoteEntry.js',
  },

  shared: {
    ...shareAll({
      singleton: true,
      strictVersion: true,
      requiredVersion: 'auto'
    }),
  },
});

// projects/shell/src/app/app.routes.ts
export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
  },
  {
    path: 'dashboard',
    loadChildren: () => import('dashboard/routes').then(m => m.DASHBOARD_ROUTES)
  },
  {
    path: 'products',
    loadChildren: () => import('products/routes').then(m => m.PRODUCTS_ROUTES)
  },
  {
    path: 'admin',
    loadChildren: () => import('admin/routes').then(m => m.ADMIN_ROUTES),
    canMatch: [adminGuard]
  }
];

// projects/shell/src/decl.d.ts
declare module 'dashboard/routes';
declare module 'products/routes';
declare module 'admin/routes';

Remote Configuration

// projects/dashboard/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');

module.exports = withModuleFederationPlugin({
  name: 'dashboard',

  exposes: {
    './routes': './projects/dashboard/src/app/dashboard.routes.ts',
    './DashboardComponent': './projects/dashboard/src/app/dashboard/dashboard.component.ts',
  },

  shared: {
    ...shareAll({
      singleton: true,
      strictVersion: true,
      requiredVersion: 'auto'
    }),
  },
});

// projects/dashboard/src/app/dashboard.routes.ts
import { Routes } from '@angular/router';

export const DASHBOARD_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(m => m.DashboardComponent)
  },
  {
    path: 'analytics',
    loadComponent: () => import('./analytics/analytics.component')
      .then(m => m.AnalyticsComponent)
  },
  {
    path: 'reports',
    loadComponent: () => import('./reports/reports.component')
      .then(m => m.ReportsComponent)
  }
];

Dynamic Remote Loading

Static remote configuration (hardcoded URLs in webpack config) works for development but fails in production where remote URLs differ per environment. Dynamic remote loading solves this by fetching the MFE registry at runtime — typically from a backend API or a configuration file. This means you can add, remove, or update micro-frontends without rebuilding and redeploying the shell application. Think of it like a TV with a channel guide: the TV (shell) does not need to know about every channel at manufacturing time. It downloads the channel list on startup, and new channels can appear without replacing the TV.
// shell/src/app/services/mfe-registry.service.ts
import { loadRemoteModule } from '@angular-architects/module-federation';

export interface MfeConfig {
  name: string;
  remoteEntry: string;
  exposedModule: string;
  displayName: string;
  route: string;
  icon?: string;
}

@Injectable({ providedIn: 'root' })
export class MfeRegistryService {
  private http = inject(HttpClient);
  private router = inject(Router);
  
  private mfes = signal<MfeConfig[]>([]);
  readonly availableMfes = this.mfes.asReadonly();
  
  async loadRegistry(): Promise<void> {
    // Load MFE configuration from backend
    const config = await firstValueFrom(
      this.http.get<{ mfes: MfeConfig[] }>('/api/mfe-config')
    );
    
    this.mfes.set(config.mfes);
    this.registerRoutes(config.mfes);
  }
  
  private registerRoutes(mfes: MfeConfig[]): void {
    const routes: Routes = mfes.map(mfe => ({
      path: mfe.route,
      loadChildren: () => loadRemoteModule({
        type: 'module',
        remoteEntry: mfe.remoteEntry,
        exposedModule: mfe.exposedModule
      }).then(m => m.routes)
    }));
    
    // Add routes dynamically
    this.router.resetConfig([
      ...this.router.config.filter(r => r.path !== '**'),
      ...routes,
      { path: '**', redirectTo: '' }
    ]);
  }
}

// shell/src/app/app.component.ts
@Component({
  template: `
    <app-header />
    
    <nav class="sidebar">
      @for (mfe of registry.availableMfes(); track mfe.name) {
        <a [routerLink]="['/', mfe.route]" routerLinkActive="active">
          @if (mfe.icon) {
            <mat-icon>{{ mfe.icon }}</mat-icon>
          }
          {{ mfe.displayName }}
        </a>
      }
    </nav>
    
    <main>
      <router-outlet />
    </main>
  `
})
export class AppComponent implements OnInit {
  registry = inject(MfeRegistryService);
  
  async ngOnInit() {
    await this.registry.loadRegistry();
  }
}

Shared State & Communication

Shared State Service

Cross-MFE state is the hardest problem in micro-frontend architecture. The temptation is to use a global NgRx store, but that creates tight coupling — the very thing micro-frontends are designed to avoid. The better approach is a minimal shared state service that only holds truly global data (authenticated user, theme, shopping cart) while each MFE manages its own feature state internally.
Pitfall: Do not put feature-specific state in the shared state service. If the “products” MFE needs filter state, that belongs in the products MFE, not in shared state. The rule: if only one MFE reads it, it is not shared state.
// shared-lib/src/lib/shared-state.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface User {
  id: string;
  name: string;
  email: string;
  roles: string[];
}

export interface GlobalState {
  user: User | null;
  theme: 'light' | 'dark';
  notifications: Notification[];
  cart: CartItem[];
}

@Injectable({ providedIn: 'root' })
export class SharedStateService {
  private state = signal<GlobalState>({
    user: null,
    theme: 'light',
    notifications: [],
    cart: []
  });
  
  // Selectors
  readonly user = computed(() => this.state().user);
  readonly isAuthenticated = computed(() => this.state().user !== null);
  readonly theme = computed(() => this.state().theme);
  readonly notifications = computed(() => this.state().notifications);
  readonly cart = computed(() => this.state().cart);
  readonly cartCount = computed(() => this.state().cart.length);
  readonly cartTotal = computed(() => 
    this.state().cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  // Actions
  setUser(user: User | null) {
    this.state.update(s => ({ ...s, user }));
  }
  
  setTheme(theme: 'light' | 'dark') {
    this.state.update(s => ({ ...s, theme }));
    document.documentElement.classList.toggle('dark', theme === 'dark');
  }
  
  addNotification(notification: Notification) {
    this.state.update(s => ({
      ...s,
      notifications: [...s.notifications, notification]
    }));
  }
  
  removeNotification(id: string) {
    this.state.update(s => ({
      ...s,
      notifications: s.notifications.filter(n => n.id !== id)
    }));
  }
  
  addToCart(item: CartItem) {
    this.state.update(s => {
      const existing = s.cart.find(i => i.productId === item.productId);
      if (existing) {
        return {
          ...s,
          cart: s.cart.map(i => 
            i.productId === item.productId 
              ? { ...i, quantity: i.quantity + item.quantity }
              : i
          )
        };
      }
      return { ...s, cart: [...s.cart, item] };
    });
  }
  
  removeFromCart(productId: string) {
    this.state.update(s => ({
      ...s,
      cart: s.cart.filter(i => i.productId !== productId)
    }));
  }
  
  clearCart() {
    this.state.update(s => ({ ...s, cart: [] }));
  }
}

Event Bus for Cross-MFE Communication

An event bus decouples micro-frontends by enabling publish/subscribe communication. The products MFE does not need to know that the header MFE exists — it just emits a “cart:itemAdded” event, and anyone who cares can listen. This is the same pattern as browser DOM events (a button does not know who is listening to its click), applied at the application architecture level.
Practical tip: Always include a source field in your events so you can debug which MFE emitted what. In production, log events with timestamps to reconstruct the sequence of cross-MFE interactions when debugging issues.
// shared-lib/src/lib/event-bus.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable, filter, map } from 'rxjs';

export interface MfeEvent<T = any> {
  type: string;
  source: string;
  payload: T;
  timestamp: Date;
}

@Injectable({ providedIn: 'root' })
export class EventBusService {
  private events$ = new Subject<MfeEvent>();
  
  emit<T>(type: string, payload: T, source: string): void {
    this.events$.next({
      type,
      source,
      payload,
      timestamp: new Date()
    });
  }
  
  on<T>(type: string): Observable<MfeEvent<T>> {
    return this.events$.pipe(
      filter(event => event.type === type)
    );
  }
  
  onMultiple<T>(types: string[]): Observable<MfeEvent<T>> {
    return this.events$.pipe(
      filter(event => types.includes(event.type))
    );
  }
  
  // Predefined events
  emitNavigation(path: string, source: string) {
    this.emit('navigation', { path }, source);
  }
  
  emitUserAction(action: string, data: any, source: string) {
    this.emit('userAction', { action, data }, source);
  }
  
  emitError(error: Error, source: string) {
    this.emit('error', { message: error.message, stack: error.stack }, source);
  }
}

// Usage in remote MFE
@Component({...})
export class ProductDetailComponent {
  private eventBus = inject(EventBusService);
  private sharedState = inject(SharedStateService);
  
  addToCart(product: Product) {
    // Update shared state
    this.sharedState.addToCart({
      productId: product.id,
      name: product.name,
      price: product.price,
      quantity: 1
    });
    
    // Emit event for other MFEs
    this.eventBus.emit('cart:itemAdded', {
      productId: product.id,
      productName: product.name
    }, 'products');
  }
}

// Listening in shell
@Component({...})
export class HeaderComponent {
  private eventBus = inject(EventBusService);
  private snackBar = inject(MatSnackBar);
  
  constructor() {
    this.eventBus.on<{ productName: string }>('cart:itemAdded').subscribe(event => {
      this.snackBar.open(`${event.payload.productName} added to cart`, 'View Cart', {
        duration: 3000
      });
    });
  }
}

Shared UI Library

// shared-ui/src/lib/components/button/button.component.ts
@Component({
  selector: 'shared-button',
  standalone: true,
  template: `
    <button 
      [class]="buttonClass()"
      [disabled]="disabled()"
      [type]="type()"
    >
      @if (loading()) {
        <shared-spinner size="small" />
      }
      <ng-content />
    </button>
  `
})
export class SharedButtonComponent {
  variant = input<'primary' | 'secondary' | 'danger'>('primary');
  size = input<'small' | 'medium' | 'large'>('medium');
  disabled = input(false);
  loading = input(false);
  type = input<'button' | 'submit'>('button');
  
  buttonClass = computed(() => {
    return `btn btn-${this.variant()} btn-${this.size()}`;
  });
}

// shared-ui/src/lib/components/card/card.component.ts
@Component({
  selector: 'shared-card',
  standalone: true,
  template: `
    <div class="card" [class.card-elevated]="elevated()">
      @if (title()) {
        <div class="card-header">
          <h3>{{ title() }}</h3>
          <ng-content select="[card-actions]" />
        </div>
      }
      <div class="card-body">
        <ng-content />
      </div>
      @if (hasFooter) {
        <div class="card-footer">
          <ng-content select="[card-footer]" />
        </div>
      }
    </div>
  `
})
export class SharedCardComponent {
  title = input<string>();
  elevated = input(false);
  
  @ContentChild('card-footer') hasFooter = false;
}

// Export all shared components
// shared-ui/src/index.ts
export * from './lib/components/button/button.component';
export * from './lib/components/card/card.component';
export * from './lib/components/spinner/spinner.component';
export * from './lib/components/modal/modal.component';
export * from './lib/components/table/table.component';
export * from './lib/components/form-field/form-field.component';

Deployment Strategy

# docker-compose.mfe.yml
version: '3.8'

services:
  shell:
    build:
      context: ./projects/shell
      dockerfile: Dockerfile
    ports:
      - "4200:80"
    environment:
      - DASHBOARD_URL=http://dashboard:80
      - PRODUCTS_URL=http://products:80
      - ADMIN_URL=http://admin:80
    depends_on:
      - dashboard
      - products
      - admin

  dashboard:
    build:
      context: ./projects/dashboard
      dockerfile: Dockerfile
    ports:
      - "4201:80"

  products:
    build:
      context: ./projects/products
      dockerfile: Dockerfile
    ports:
      - "4202:80"

  admin:
    build:
      context: ./projects/admin
      dockerfile: Dockerfile
    ports:
      - "4203:80"

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - shell
      - dashboard
      - products
      - admin
# nginx.conf for micro-frontends
upstream shell {
    server shell:80;
}

upstream dashboard {
    server dashboard:80;
}

upstream products {
    server products:80;
}

upstream admin {
    server admin:80;
}

server {
    listen 80;
    
    location / {
        proxy_pass http://shell;
    }
    
    location /dashboard/ {
        proxy_pass http://dashboard/;
    }
    
    location /products/ {
        proxy_pass http://products/;
    }
    
    location /admin/ {
        proxy_pass http://admin/;
    }
    
    # Remote entries
    location ~ ^/(.+)/remoteEntry.js$ {
        set $mfe $1;
        proxy_pass http://$mfe/remoteEntry.js;
    }
}

Best Practices

Share Wisely

Only share what’s necessary - over-sharing leads to coupling

Version Compatibility

Test shared dependency versions across all MFEs

Consistent Styling

Use a shared design system with CSS custom properties

Error Boundaries

Isolate MFE failures to prevent cascade effects
// Error boundary wrapper
@Component({
  selector: 'mfe-error-boundary',
  template: `
    @if (hasError()) {
      <div class="mfe-error">
        <mat-icon>error_outline</mat-icon>
        <h3>Something went wrong</h3>
        <p>The {{ mfeName() }} module failed to load.</p>
        <button mat-button (click)="retry()">Retry</button>
      </div>
    } @else {
      <ng-content />
    }
  `
})
export class MfeErrorBoundaryComponent implements OnInit, ErrorHandler {
  mfeName = input.required<string>();
  hasError = signal(false);
  
  handleError(error: Error): void {
    console.error(`MFE Error [${this.mfeName()}]:`, error);
    this.hasError.set(true);
  }
  
  retry() {
    this.hasError.set(false);
  }
}

Next: Nx Monorepo

Scale your Angular projects with Nx monorepo tooling