SSR & Hydration
Angular SSR and Incremental Hydration with Native Federation — the v4 node-preload launch model, host-singleton bridging, federated routes under SSR, boot order, and readiness gating.
The Angular adapter supports Angular SSR and Incremental Hydration — both for hosts and remotes. The server side is driven by a node --import preload that registers the federation loader before Angular evaluates; most of the wiring is done by the init schematic when it detects an SSR-enabled project. This page documents what it generates and the moving parts behind it.
Version note. This page tracks the v4 adapter — @angular-architects/native-federation 22.x on Angular 22 (or @angular-architects/native-federation-v4 21.2.x on Angular 20/21) — and @softarc/native-federation-orchestrator 4.2.x (the runtime). A complete worked example is the Native Federation Angular SSR playground.
On this page
- Enabling SSR
- The ordering problem
- Production: the node-preload launch contract
- Dev SSR (
ng serve) - Wiring the loader into Angular
- Federated routes under SSR
- Boot order
- Externals on the server
- Manifests & CORS
- Environment variables
Enabling SSR
Add SSR to your Angular project the usual way, then run (or re-run) the federation init:
ng add @angular/ssr --project host
# then the adapter's init / ng-add schematic
When the schematic sees build.options.ssr.entry it wires the SSR-specific pieces:
- Sets
ssr: trueon the federationbuildtarget. The builder uses this flag to route externals through Angular'sexternalDependenciesinstead of an esbuild plugin (the SSR build path doesn't run that plugin — see Externals). - Adds
app.use(cors())to the generatedserver.ts(federated remotes load across origins). - Switches the scaffolded
RenderMode.Prerender→RenderMode.Serverinapp.routes.server.ts— federated remotes load at runtime and can't be prerendered. - Forces
security.allowedHosts: ['localhost']on theesbuildtarget so@angular/ssrdoesn't reject the localhostHostheader and silently fall back to CSR.
Server-side init happens at launch via the preload below — you still wire the prod start command yourself.
The ordering problem
Every shared singleton must resolve to exactly one instance. If a remote loaded during SSR pulls in a second @angular/core, you get NG0203 ("inject() must be called from an injection context") / "JIT compilation failed" — Angular's DI and compiler assume a single core instance.
The orchestrator dedupes by intercepting module resolution through a module.register() loader hook. But module.register() only intercepts modules loaded after it runs, so the hook must be installed before any @angular/* module is loaded. That can't happen in server.ts:
server.tsstatically imports@angular/ssr/node.@angular/buildprepends the@angular/ssrapp-engine registration into the emitted server bundle, so@angular/*is in the entry's static import graph regardless of what your source does.
ESM evaluates (and, for externals, loads) a module's entire static import graph before the module body runs — so by the time any code in the entry executes, @angular/* is already loaded. The registration must therefore happen in a separate, earlier module graph. That's what the preload is for.
Production: the node-preload launch contract
Launch the server through the adapter's preload:
node --import @angular-architects/native-federation/node-preload \
dist/<app>/server/server.mjs
--import modules are fully evaluated (top-level await awaited) before Node loads the entry point. The preload installs the loader hook, then Node loads server.mjs — whose static @angular/* imports are now intercepted. Because server.mjs stays the main module, its own isMainModule(import.meta.url) listen-guard fires unchanged. The build emits the CLI's server.mjs as-is; the preload is the only SSR-specific piece, applied purely at launch. One preload serves the host and every remote.
What the preload (@angular-architects/native-federation/node-preload) does:
import { initNodeFederation } from '@softarc/native-federation-orchestrator/node';
// Locate the app's browser output dir (holds the federation artifacts):
// 1. NF_BROWSER_DIR env override (absolute or cwd-relative), else
// 2. ../browser relative to the launched entry (process.argv[1]) — the default
// dist/<app>/{server,browser} layout.
const browserDir = resolveBrowserDir();
const manifestPath = join(browserDir, 'federation.manifest.json'); // host only
const hostRemoteEntry = pathToFileURL(join(browserDir, 'remoteEntry.json')).href;
const { loadRemoteModule } = await initNodeFederation(
existsSync(manifestPath) ? manifestPath : {}, // remotes have no manifest → {} still works
{ hostRemoteEntry, hostInstances: 'all' }, // bridge all shared singletons
);
globalThis['__NF_HOST_SERVER_LOADER__'] = loadRemoteModule; // read by the app (see Wiring)
hostInstances: 'all' bridges the host's shared singletons so a remote's @angular/core (and its secondary entry points, e.g. @angular/core/rxjs-interop) resolve to the host's already-loaded instance instead of a duplicate. See Orchestrator → Node.js / SSR for the mechanism (__NF_HOST_INSTANCES__, the nf-host: synthesis, setHostInstances).
Beyond ordering, the shipped preload also:
- Reconciles the manifest's expected remotes against what actually registered and logs the gap:
[native-federation] 1 remote(s) not registered at startup: mfe2 — their federated regions will render empty. - Publishes a
FederationStatusonglobalThis.__NF_FEDERATION_STATUS__for use as a readiness probe:interface FederationStatus { ok: boolean; // true iff every required remote registered initialized: string[]; // remotes that registered at startup missing: string[]; // expected-but-unregistered remotes error?: string; // init-level failure, if init rejected outright } - Honors
NF_REQUIRE_REMOTES(see Environment variables) —process.exit(1)when a required remote is missing, so a platform supervisor restarts the host instead of serving empty regions.
Dev SSR (ng serve)
--import is impossible under ng serve (there's no Node launch command), so dev uses a different mechanism for the same ordering guarantee: an esbuild inject + externalize of the orchestrator's /node entry, so the loader hook fires inside Vite's SSR graph.
- The server-side loader is published synchronously at module-eval, but
initNodeFederationruns lazily on the first remote load, once, memoised — bounded byNF_DEV_SSR_INIT_TIMEOUT_MS(default 10 s). - Dev capture uses
hostInstances: { load: (s) => import(s) }so singletons are captured through the host realm (Vite's SSR graph), not the orchestrator's. - Dev publishes no
__NF_FEDERATION_STATUS__(only the prod--importpreload does) and init is lazy, so there is no eager readiness —/healthzis prod-only and returns 503 in dev.
Because dev init is lazy and memoised, a mis-ordered dev host doesn't render empty and then heal — it renders the RemoteUnavailable placeholder and stays that way until the dev server is restarted (a file save won't re-init). Gate dev startup on the remotes' remoteEntry.json (see Boot order).
Wiring the loader into Angular
The router needs one call — loadRemote(remoteName, exposedModule) — that works in both the browser and on the server. The implementation differs per platform, so it lives behind an injection token:
// load-remote-module.token.ts
export const LOAD_REMOTE_MODULE = new InjectionToken<LoadRemoteModule>('LOAD_REMOTE_MODULE');
export const SERVER_LOADER_GLOBAL_KEY = '__NF_HOST_SERVER_LOADER__';
// app.config.ts (browser): the adapter's import-map-based loadRemoteModule
{ provide: LOAD_REMOTE_MODULE,
useValue: (remoteName, exposedModule) => loadRemoteModule({ remoteName, exposedModule }) }
// app.config.server.ts (server): read the loader the preload published on the global slot
const serverLoader = (remoteName, exposedModule) => {
const loader = globalThis[SERVER_LOADER_GLOBAL_KEY]; // set by node-preload
if (!loader) throw new Error('server federation loader not initialized …');
return loader(remoteName, exposedModule);
};
{ provide: LOAD_REMOTE_MODULE, useValue: serverLoader }
The token is read synchronously in routes (so you stay inside Angular's injection context), but the actual call is deferred onto a microtask. The server loader throws synchronously if federation isn't initialised yet; deferring turns that synchronous throw into a catchable rejection, so a route can fall back to a placeholder instead of crashing the whole render:
function loadRemote(remoteName: string, exposedModule: string): Promise<unknown> {
const load = inject(LOAD_REMOTE_MODULE); // capture synchronously
return Promise.resolve().then(() => load(remoteName, exposedModule)); // defer the call
}
Federated routes under SSR
Angular's SSR build derives a route manifest by statically crawling the host's router config at build time, and AngularNodeAppEngine only server-renders URLs present in that manifest.
A federated loadChildren callback cannot be resolved during that build-time crawl (the server federation loader isn't initialised then), so its child routes — e.g. /todos/:id — never enter the manifest. Symptom: /todos renders, client-side navigation to /todos/:id works, but a direct request to /todos/:id returns a 404 from Express. (This is about discovering the route shape, not enumerating ids — a server-rendered :id is stored as a single wildcard pattern, so once the shape is known any id renders on demand.)
The fix: host owns the route shape. Mount the feature with loadComponent children instead of a federated loadChildren:
{
path: 'todos',
children: [
{ path: '', loadComponent: () => loadRemote('mfe1', './List')
.then(m => m as Type<unknown>).catch(() => RemoteUnavailable) },
{ path: ':id', loadComponent: () => loadRemote('mfe1', './Detail')
.then(m => m as Type<unknown>).catch(() => RemoteUnavailable) },
],
}
A loadComponent path is registered statically (its callback isn't invoked at build time), so both /todos and /todos/:id land in the manifest and server-render on direct request. The remote owns the components; the host owns the route shape; :id binds to the detail component via withComponentInputBinding(). Trade-off: adding a nested child later requires a host change.
Long-term alternative (not yet shipped). Keep the federated loadChildren (remote owns its routes) and make the child routes visible to the build via a manifest merge: each remote already emits its own angular-app-manifest.mjs; a post-build step reads it, prefixes the routes (/ → /todos, /* → /todos/*), and merges them into the host manifest — avoiding both hooking Angular's route extraction and executing remote code at build time. This belongs upstream in the adapter.
Boot order
The host's federation init fetches each remote's remoteEntry.json over HTTP once, at start-up, and never re-fetches. Therefore remotes must be listening before the host boots. If the host starts first it can't reach the remotes, those remotes are skipped (strictRemoteEntry defaults to false), and their regions render empty for the lifetime of the host process, until it is restarted.
In the example a launcher enforces it: scripts/start-all.mjs (prod) and scripts/start-dev.mjs (dev) start the remotes, wait for each remoteEntry.json to respond, then start the host. Ctrl+C (or any child crashing) tears everything down together. The remote ports are pinned by the host's federation.manifest.json; if you start a remote on a different port, update the manifest to match.
In production, explicit ordering can instead be replaced by NF_REQUIRE_REMOTES + a restart-on-exit supervisor (k8s, PM2, systemd): the host boots too early → required remote missing → process.exit(1) → the supervisor restarts it → it converges once the remotes are up. Note this needs the exit gate — a /healthz probe without NF_REQUIRE_REMOTES keeps an unhealthy host out of rotation but never recovers it (there is no in-process re-init), so it stays down until manually restarted.
Externals on the server
On the browser side, externals are excluded from the Angular bundle by an esbuild plugin the adapter installs. That plugin is bypassed for SSR builds, so the adapter instead sets options.externalDependencies = externals on the underlying Angular target. The result is the same — Angular doesn't pre-bundle anything Native Federation will load at runtime — but uses Angular's first-class hook, leaving @angular/* etc. as real import statements to shared chunks.
One consequence: @angular/core infers ngServerMode from the bundling step. Because Native Federation reuses the same shared @angular/core bundle on the server and in the browser, the adapter patches node_modules/@angular/core/fesm2022/core.mjs with a small runtime check:
if (typeof globalThis.ngServerMode === 'undefined')
globalThis.ngServerMode = (typeof window === 'undefined') ? true : false;
The patch is applied automatically by the adapter on every build. Treat it as an implementation detail.
Manifests & CORS
- Same manifest, both worlds. Use the same
federation.manifest.jsonfor browser and Node. The preload reads it from the app's browser output dir, so they stay in sync. - Production URLs. If your remotes are deployed across origins, list absolute URLs in the manifest. The Node side will fetch
remoteEntry.jsonfrom those URLs; make sure they're reachable from your SSR runtime. - CORS. The schematic enables
corson the generated Express app so cross-origin requests don't bounce. Adjust the policy inserver.tsif you need stricter rules. - I18N. When SSR runs alongside multiple locales, the dev server only serves one locale at a time (Angular's limitation). Production builds emit one folder per locale; route accordingly. See I18N.
Environment variables
PORT— server listen port (perserver.ts).NF_BROWSER_DIR— override for the preload when theserverandbrowserdirs aren't../-adjacent (custom output layout, containers that relocate one of them).NF_REQUIRE_REMOTES— strict readiness gate for the preload (default off).allrequires every manifest remote; a comma list (mfe1,mfe2) requires a subset. If a required remote didn't register at boot, the preloadprocess.exit(1)s.NF_DEV_SSR_INIT_TIMEOUT_MS— bound on the lazy dev init (default10000).
Related
- Orchestrator — Node.js / SSR —
initNodeFederation, themodule.register()loader hook, and host-singleton bridging. - Native Federation Angular SSR example — the full host + two-remote workspace this page describes.
- Runtime overview.
- SSR & Hydration with Native Federation for Angular — the long-form article behind these features.