Skip to content
Angular ng microfrontends 4 min read

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.

ConcernShell (host)Remote
Global layout & navOwns itRenders inside the shell’s outlet
Top-level routingOwns itOwns its own child routes
AuthenticationProvides shared serviceConsumes it
DeploymentDeployed onceDeployed independently
ExposesNothing (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: true and strictVersion: true to 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.
Last updated June 14, 2026
Was this helpful?