Adopting a modern micro-frontends architecture is often framed simply as a way to split large enterprise codebases across autonomous product teams. In practice, building remotes that only work when embedded inside a host shell creates fragile integration boundaries, breaks local workflows, and limits deployment flexibility.
A resilient micro-frontends architecture treats every remote application as a dual-target delivery artifact: a fully functional, autonomous Single Page Application (SPA) that can be deployed independently to its own domain, or dynamically mounted as a guest module inside an enterprise host application.

1. Dual-Target Entry and Lifecycle in Micro-Frontends Architecture
Instead of maintaining separate build targets for standalone and embedded builds, use a unified entry file (src/main.ts). The bundle checks the DOM at runtime to choose between immediate auto-bootstrapping and exporting a lifecycle-managed mount function.
// src/main.ts
import { bootstrapStandalone } from './bootstrap-standalone';
import { mountRemote } from './bootstrap-remote';
import type { HostContext, MfeHandle } from './contracts/mfe-contract';
// Check if root element exists in standalone index.html
const isStandalone = !!document.querySelector('app-root');
if (isStandalone) {
bootstrapStandalone();
}
// Named export for module federation loaders
export function mount(container: HTMLElement, context: HostContext): MfeHandle {
return mountRemote(container, context);
}
// Fallback registry for script-based loaders
if (typeof window !== 'undefined') {
((window as any).__MFE_REGISTRY__ ??= {})['feature-analytics'] = { mount };
}
Complete Teardown Lifecycle for Micro-Frontends
A production-ready remote must clean up after itself when the host navigates away. The mount function returns an explicit unmount handle responsible for destroying the component tree and removing injected DOM nodes.
// src/bootstrap-remote.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { ApplicationRef } from '@angular/core';
import { FeatureRootComponent } from './app/feature-root.component';
import { buildCoreProviders } from './app/core/providers';
import { HostContext, MfeHandle, MFE_CONTRACT_VERSION } from './contracts/mfe-contract';
export function mountRemote(container: HTMLElement, context: HostContext): MfeHandle {
if (context.contractVersion !== MFE_CONTRACT_VERSION) {
throw new Error(`Contract mismatch: Expected ${MFE_CONTRACT_VERSION}, got ${context.contractVersion}`);
}
const appElement = document.createElement('app-feature-root');
container.appendChild(appElement);
let appRef: ApplicationRef | null = null;
bootstrapApplication(FeatureRootComponent, {
providers: buildCoreProviders(context)
}).then(ref => {
appRef = ref;
});
return {
unmount: () => {
appRef?.destroy();
appElement.remove();
}
};
}
2. Strict Host-Remote Contract Governance
Cross-boundary communication within a federated micro-frontends architecture must stay framework-agnostic. Never pass framework state stores, RxJS subjects, or class instances across the host-remote boundary. Restrict the contract to structural interfaces and asynchronous primitives.
TypeScript
// src/contracts/mfe-contract.ts
export const MFE_CONTRACT_VERSION = '1.0.0';
export interface HostUser {
id: string;
name: string;
roles: string[];
}
export interface HostContext {
contractVersion: string;
remoteBaseUrl: string;
baseHref: string;
user: HostUser;
theme: 'light' | 'dark';
getAccessToken: () => Promise<string>;
onLangChange: (callback: (lang: string) => void) => () => void;
navigateByUrl: (url: string) => void;
}
export interface MfeHandle {
unmount: () => void;
}
Host vs. Remote Responsibility Matrix
| Concern | Host Shell Responsibility | Remote MFE Responsibility |
| Authentication | Token refresh loops, session expiration, credential storage | Requests a fresh token on-demand per API call |
| Routing | Top-level application shell and cross-app path transitions | Internal feature sub-routing |
| Styling | Global viewport layouts, shared typography baseline | Encapsulated component styles and scoped overlays |
| Lifecycle | Triggering mount() and unmount() on route changes | Memory cleanup, event listener teardown, DOM cleanup |
3. Unified Dependency Injection Pipeline
Feature components should never contain conditional runtime checks like if (isEmbedded). Instead, route both standalone and embedded initialization paths through a shared provider factory using standard Dependency Injection
patterns.
TypeScript
// src/app/core/providers.ts
import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
import { provideRouter } from '@angular/router';
import { APP_BASE_HREF } from '@angular/common';
import { HostContext } from '../../contracts/mfe-contract';
import { FEATURE_ROUTES } from '../feature.routes';
export const HOST_CONTEXT = new InjectionToken<HostContext>('HOST_CONTEXT');
export function buildCoreProviders(context: HostContext): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: HOST_CONTEXT, useValue: context },
{ provide: APP_BASE_HREF, useValue: context.baseHref },
provideRouter(FEATURE_ROUTES)
]);
}
In standalone mode, generate an autonomous mock context that satisfies the exact same interface using local defaults:
// src/app/core/mock-context.ts
import { HostContext, MFE_CONTRACT_VERSION } from '../../contracts/mfe-contract';
export function createStandaloneContext(): HostContext {
return {
contractVersion: MFE_CONTRACT_VERSION,
remoteBaseUrl: '/',
baseHref: '/',
user: { id: 'dev-user', name: 'Local Developer', roles: ['admin'] },
theme: 'light',
getAccessToken: async () => localStorage.getItem('dev_token') ?? 'mock-token',
onLangChange: (cb) => () => {},
navigateByUrl: (url) => window.history.pushState({}, '', url)
};
}
4. Zero-Trust Authentication in Micro-Frontends Architecture
Remotes should treat the host environment as the single source of truth for authentication. Storing JWTs long-term in the remote or executing separate refresh routines introduces race conditions and session drift.
Use an HTTP interceptor that retrieves a fresh bearer token asynchronously before dispatching API requests.
// src/app/core/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs';
import { HOST_CONTEXT } from './providers';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const context = inject(HOST_CONTEXT);
// Allow local static assets (i18n, icons) to bypass token injection
if (!req.url.startsWith('/api')) {
return next(req);
}
return from(context.getAccessToken()).pipe(
switchMap(token => {
const authorizedRequest = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
return next(authorizedRequest);
})
);
};
5. CSS Isolation and Overlay Portals
CSS collisions in a multi-team front-end setup typically happen in two areas: global selector bleed and portal overlays.
Class Scoping
Namespace all design tokens, component variables, and reset rules under a root scope selector:
// src/styles.scss
.mfe-scope {
--primary-color: #2ba7ff;
--accent-color: #fe881b;
button {
font-family: inherit;
border-radius: 4px;
}
}
Managing Overlay Portals
Modals, tooltips, and dropdowns (such as those powered by the Angular CDK Overlay
or React Portals) often render directly into document.body, escaping .mfe-scope container constraints. Override the overlay container to automatically append your scope class to floating wrappers.
// src/app/core/custom-overlay-container.ts
import { Injectable } from '@angular/core';
import { OverlayContainer } from '@angular/cdk/overlay';
@Injectable()
export class CustomOverlayContainer extends OverlayContainer {
protected override _createContainer(): void {
super._createContainer();
this._containerElement.classList.add('mfe-scope');
}
}
Reference-Counted Stylesheet Injection
When mounting remotes dynamically, inject their compiled CSS links into <head> dynamically, using a reference counter to ensure styles are only removed once all active instances of the remote unmount.
// src/utils/style-loader.ts
const styleRefCount = new Map<string, number>();
export function ensureStyles(href: string): void {
const count = styleRefCount.get(href) ?? 0;
styleRefCount.set(href, count + 1);
if (count === 0) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.dataset.mfeStyle = href;
document.head.appendChild(link);
}
}
export function releaseStyles(href: string): void {
const count = (styleRefCount.get(href) ?? 1) - 1;
if (count <= 0) {
styleRefCount.delete(href);
document.querySelector(`link[data-mfe-style="${href}"]`)?.remove();
} else {
styleRefCount.set(href, count);
}
}
6. Dynamic Base URLs and Asset Resolution
When remote chunks are loaded from a CDN or a separate origin, relative asset paths like ./assets/logo.png will resolve against the host origin, triggering 404 errors.
Construct all asset and localization paths using context.remoteBaseUrl:
// src/app/core/asset-url.pipe.ts
import { Pipe, PipeTransform, inject } from '@angular/core';
import { HOST_CONTEXT } from './providers';
@Pipe({ name: 'assetUrl', standalone: true })
export class AssetUrlPipe implements PipeTransform {
private context = inject(HOST_CONTEXT);
transform(path: string): string {
const base = this.context.remoteBaseUrl.replace(/\/+$/, '');
const cleanPath = path.replace(/^\/+/, '');
return `${base}/${cleanPath}`;
}
}
Key Takeaways: Scaling a Micro-Frontends Architecture
- Dual-Target Delivery: A micro-frontend can be built as a full production SPA and a remote module simultaneously from a single build artifact.
- Structural Contracts: Protect host-remote communication by passing only plain data, string identifiers, and async functions across boundaries.
- Centralized DI Configuration: Keep feature code clean by letting Dependency Injection resolve the runtime context rather than scattering conditional logic throughout components.
- Host-Governed Auth: Let the host manage token lifecycles and retrieval, treating the remote as an unprivileged consumer.
- Defensive Styling: Combine scoped root classes with reference-counted stylesheet injection and custom overlay containers to prevent style leaks.
Frequently Asked Questions
Why not use iframes for complete isolation in a micro-frontends architecture?
Iframes provide strict isolation but introduce heavy memory overhead, complex cross-window routing, and rigid layouts that make responsive modals and cross-application drag-and-drop difficult. A contract-driven module architecture offers native DOM integration while preserving runtime safety.
How do we handle shared dependencies like core frameworks?
Tools like Module Federation
allow sharing singleton dependencies across hosts and remotes. When dependencies drift across major versions, compiling the remote with its own runtime and isolating its styles allows independent deployments without breaking the host.
What happens if the host passes an incompatible contract version?
The remote should inspect context.contractVersion during the mount() lifecycle. If the version is unsupported, throw a descriptive error immediately so the host can catch it and display a fallback error state rather than failing silently.
How does client-side routing work without colliding with the host router?
The remote configures its base path dynamically using context.baseHref. The host router manages top-level path matching, while the remote router manages internal child routes. Cross-application navigations are delegated back to context.navigateByUrl().
