Shell & Remote Apps
A micro frontend architecture splits a large application into a single host shell and several independently deployed remote apps. The shell owns the layout, top-level routing, and shared infrastructure (authentication, theming, the navigation chrome), while each remote ships its own features as a self-contained Angular application. This page explains the shell-and-remote pattern, how to route between remotes lazily, and how to bootstrap a composition root that wires everything together at runtime.
The shell-and-remote model
The shell (also called the host or container) is the entry point your users load. It is a thin Angular application whose main job is composition: it renders the global UI and decides which remote to mount for a given URL. It contains almost no business logic of its own.
A remote is a fully functional Angular application that also exposes one or more entry points (a route config, a standalone component, a service) for the shell to consume. Each remote can be developed, tested, and deployed by a separate team without redeploying the shell.
| Concern | Shell (host) | Remote |
|---|---|---|
| Global layout & nav | Owns it | Renders inside the shell’s outlet |
| Top-level routing | Owns it | Owns its own child routes |
| Authentication | Provides shared service | Consumes it |
| Deployment | Deployed once | Deployed independently |
| Exposes | Nothing (consumes) | Routes / components / services |
The golden rule: the shell should depend on URLs, not on remote source code. The actual remote bundle is fetched over HTTP at runtime, which is what makes independent deployment possible.
Exposing a remote
With Native Federation (the recommended approach for modern Angular), each remote declares what it shares in federation.config.js. The remote exposes a route file rather than individual components, so it keeps control of its internal navigation.
// remote (orders) — src/app/orders/orders.routes.ts
import { Routes } from '@angular/router';
import { OrdersListComponent } from './orders-list.component';
import { OrderDetailComponent } from './order-detail.component';
export const ORDERS_ROUTES: Routes = [
{ path: '', component: OrdersListComponent },
{ path: ':id', component: OrderDetailComponent },
];
// remote (orders) — federation.config.js
const { withNativeFederation } = require('@angular-architects/native-federation/config');
module.exports = withNativeFederation({
name: 'orders',
exposes: {
'./routes': './src/app/orders/orders.routes.ts',
},
shared: {
'@angular/core': { singleton: true, strictVersion: true },
'@angular/common': { singleton: true, strictVersion: true },
'@angular/router': { singleton: true, strictVersion: true },
},
});
Routing between remotes from the shell
The shell maps each top-level path segment to a remote and lazily loads the remote’s exposed routes. loadRemoteModule fetches the remote bundle on demand, so a remote’s code is never downloaded until the user navigates to it.
// shell — src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
import { authGuard } from './core/auth.guard';
export const APP_ROUTES: Routes = [
{
path: 'orders',
canActivate: [authGuard],
loadChildren: () =>
loadRemoteModule('orders', './routes').then((m) => m.ORDERS_ROUTES),
},
{
path: 'catalog',
loadChildren: () =>
loadRemoteModule('catalog', './routes').then((m) => m.CATALOG_ROUTES),
},
{ path: '', redirectTo: 'catalog', pathMatch: 'full' },
{ path: '**', loadComponent: () => import('./not-found.component').then((c) => c.NotFoundComponent) },
];
The shell’s authGuard is a modern functional guard. Because authentication lives in the shell and is shared as a singleton, every remote benefits from the same session.
// shell — src/app/core/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
Bootstrapping the composition root
Native Federation needs its runtime initialized before Angular bootstraps. The shell’s main.ts becomes the composition root: it loads the remote manifest, initializes federation, and only then bootstraps the root component. This guarantees loadRemoteModule knows where each remote lives.
// shell — src/main.ts
import { initFederation } from '@angular-architects/native-federation';
initFederation('/assets/federation.manifest.json')
.catch((err) => console.error('Federation init failed', err))
.then(() => import('./bootstrap'))
.catch((err) => console.error(err));
// shell — src/bootstrap.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app/app.component';
import { APP_ROUTES } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(APP_ROUTES, withComponentInputBinding())],
});
The manifest is plain JSON, so you can swap remote URLs per environment without rebuilding the shell.
{
"orders": "http://localhost:4201/remoteEntry.json",
"catalog": "http://localhost:4202/remoteEntry.json"
}
Finally, the shell renders the navigation chrome and a single <router-outlet> that hosts whichever remote is active. Modern control flow keeps the template declarative.
<!-- shell — src/app/app.component.html -->
<header class="shell-nav">
<a routerLink="/catalog">Catalog</a>
<a routerLink="/orders">Orders</a>
@if (auth.user(); as user) {
<span class="user">{{ user.name }}</span>
} @else {
<a routerLink="/login">Sign in</a>
}
</header>
<main>
<router-outlet />
</main>
Start the shell and a remote together and the lazy load is visible in the dev server logs as the user navigates.
Output:
✔ Shell listening on http://localhost:4200
✔ Remote 'catalog' served at http://localhost:4202
[federation] resolved manifest: 2 remotes
[router] navigated /orders → loadRemoteModule('orders', './routes')
[federation] fetched http://localhost:4201/remoteEntry.json (cached)
Best Practices
- Keep the shell thin: layout, top-level routing, and shared cross-cutting services only — no feature logic.
- Have remotes expose route files, not individual components, so each remote owns its internal navigation.
- Mark framework packages as
singleton: trueandstrictVersion: trueto avoid multiple Angular instances at runtime. - Drive remote locations from an environment-specific JSON manifest so you can redeploy a remote without touching the shell.
- Always initialize federation in the composition root before bootstrapping Angular, then import a separate
bootstrap.ts. - Define ownership boundaries by URL: the shell depends on a remote’s address, never on its source code.
- Provide auth, theming, and configuration as shared singletons in the shell so every remote inherits a consistent context.