SSR & Hydration
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.
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.