Build-Time, End to End - From federation.config to remoteEntry.json
A few terms first
Part 2 explained host, remote, shared dependency, bundler, import map, semver and singleton. This article adds four more:
- External: a library that is deliberately left out of your app's bundle, so it can be loaded separately and shared.
- Exposed module: a piece of your app, such as a component or a set of routes, that other apps are allowed to load.
- Entry point: the file a build or a code scan starts from. In Angular, that's usually
src/main.ts. - Manifest: a list of remote names and the URLs of their
remoteEntry.jsonfiles.
Normal build vs. federated build
In a normal Angular build, your code and every library you use are bundled together. If three apps all use Angular, users download Angular three times.
A federated build splits things up:
- Your own code is built without the shared libraries inside it.
- Each shared library gets its own file, so the browser can download it once and reuse it.
- The build writes two small description files,
remoteEntry.jsonandimportmap.json, so the browser knows what's available.
The input: federation.config.mjs
Every federated project has a federation.config.mjs next to it. Here is the one the Angular adapter's ng add schematic generates for a remote today, taken from its template in the source code (comments shortened):
import { withNativeFederation , fromPackageJson } from ' @angular-architects/native-federation/config ' ;
export default withNativeFederation ({
name : ' mfe1 ' ,
exposes : {
' ./Component ' : ' ./src/app/app.component.ts ' ,
},
shared : fromPackageJson ({
singleton : true ,
strictVersion : true ,
requiredVersion : ' auto ' ,
build : ' package '
}) // Share all of @angular/core to prevent version mismatches .
patch ([ ' @angular/core ' ], { includeSecondaries : { keepAll : true } }),
skip : [ ' rxjs/ajax ' , ' rxjs/fetch ' , ' rxjs/testing ' , ' rxjs/webSocket ' ],
features : { denseChunking : true , },
});
Choosing what to share
There are three ways to build the shared list, all defined in share-utils.ts:
fromPackageJson()- recommended. It reads the dependencies in yourpackage.jsonand shares all of them with the settings you pass in. Exceptions are handled with.skip(),.override()and.patch().shareAll()- older version of the same idea; still works.share()- for picking libraries by hand, one by one.
If you leave shared out entirely, Native Federation behaves as if you had written fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto' }) (Core configuration).
The settings on each library
Each shared library carries four settings that matter later, when the browser decides which version to load:
- singleton - only one copy of this library may exist on the page. Frameworks like Angular need this.
- strictVersion - if another app needs a version that isn't compatible, don't just use the shared one anyway.
- requiredVersion - the range of versions this app accepts.
'auto'means "use the range frompackage.json", for example^20.1.0. - version - the exact version this app was built with. By default it's read from the installed package in
node_modules, not frompackage.json.
Example: Say your package.json lists "@angular/core": "^20.1.0" and npm install gave you 20.1.4. The build records that this app has 20.1.4 and accepts anything from ^20.1.0. The browser later uses both numbers to find one version every app can agree on. (Part 5 covers that decision in detail.)
Notice that the defaults are strict. Unless you change them, every library is shared as a strict singleton. That's safe, but it's worth knowing before you see your first version warning.
skip: leaving a library out of sharing
skip takes package names, regular expressions or small functions. The important detail: skipping a library does not remove it. It still gets installed and still works, but it's built into your app's own bundle instead of being shared.
The template above skips parts of RxJS that most apps never use at runtime. Some packages are skipped automatically, through a default skip list: Native Federation's own packages, es-module-shims and all @types/* packages.
includeSecondaries and keepAll: the sub‑paths
Many libraries have sub‑paths, called secondary entry points, like @angular/common/http or @angular/core/rxjs-interop. includeSecondaries controls whether those are shared too. Setting includeSecondaries: { keepAll: true } goes one step further: it tells the build to keep every sub‑path of a library as long as the library itself is used, even sub‑paths your app never imports.
The next section explains why the template does this for @angular/core.
How the build decides what's "used"
Sharing everything in package.json would be wasteful. Most projects list libraries they barely touch. So Native Federation has a feature called ignoreUnusedDeps, which is on by default (with-native-federation.ts). It scans your code, finds which shared libraries are actually imported, and drops the rest from the shared list.
The interesting question is where the scan starts. The answer depends on whether the project is a host or a remote, and it's easy to get wrong.
-
If the project exposes modules (a remote) - the scan starts from the exposed files only. It does not look at
main.tsorbootstrap.tsat all. You can see this inget-used-dependencies.ts: it takes the files listed inexposes, and only uses the fallback entry points when that list is empty. The Angular builder'sentryPointsoption confirms this. Its description says exposed modules “always take precedence,” so you can't use it to override or narrow them. -
If the project exposes nothing (a typical host) - the scan starts at
src/main.ts. In a federated Angular app,main.tsis tiny. It starts federation, then loads the real app with a dynamic import:
initFederation (
' federation.manifest.json ' ,
{ hostRemoteEntry : { url : ' ./remoteEntry.json ' } }
)
.catch ( err => console . error ( err ))
.then ( _ => import ( ' ./bootstrap ' ))
.catch ( err => console . error ( err ));
The scanner uses TypeScript's import detection, which sees dynamic import() calls as well as normal imports. So it follows import('./bootstrap') into bootstrap.ts, then into your app config, your routes, and the components those routes lazy‑load. Anything reachable from there counts as used.
Then the scan adds what your libraries need. If you use @angular/router, the build also counts the packages @angular/router itself imports, such as @angular/common.
Finally, sub‑paths are checked. Without keepAll, a sub‑path like @angular/core/rxjs-interop is kept only if something imports it exactly. With keepAll, it's kept whenever its parent package is used (remove-unused-deps.ts). That last rule is why the template sets keepAll on @angular/core.
Imagine two remotes on different Angular patch versions, where only one of them imports @angular/core/rxjs-interop. The other remote drops that sub‑path from its build. At runtime, the page can end up loading @angular/core from one version and rxjs-interop from another, splitting Angular across versions. The Core configuration docs warn about exactly this, and keepAll on framework libraries is the fix.
What this means for remotes
A library used only by a remote's own startup code, in main.ts or bootstrap.ts, isn't shared by that remote. It still works, because it's bundled into the remote instead. But if you expected the host and the remote to share it, check the remote's remoteEntry.json rather than assuming.
What happens when you build
With the config understood, here's what actually runs when you type ng build. The order comes from the Angular adapter's builder documentation and its source.
- Read the config -
withNativeFederation()fills in defaults, applies the skip list and works out the shared list. - Find what's used - the
ignoreUnusedDepsscan from the previous section trims the shared list. - Build the shared libraries - each one becomes its own file. Built libraries are cached in
node_modules/.cache/native-federation/<project>, so the next build can reuse them. The cache refreshes itself when installed versions or sharing settings change (Core caching). - Build the exposed modules.
- Write
remoteEntry.jsonandimportmap.json. - Hand off to Angular - the adapter is a thin wrapper around Angular's own
ApplicationBuilder. Angular builds your app as usual, with the shared libraries marked as externals so they stay out of your bundle.
Two practical notes:
- If the Angular build fails, no federation files are written at all, so a failing build can never ship a half‑updated
remoteEntry.json. - If a build ever looks stale, deleting
node_modules/.cache/native-federationforces a clean rebuild of the shared libraries.
The output: remoteEntry.json and importmap.json
remoteEntry.json: what this app offers and needs
This is the file other apps read. Its shape is defined in federation-info.contract.ts and written by write-federation-info.ts. A simplified example (real file names include content hashes and will differ):
{
"$version": "v4",
"name": "mfe1",
"exposes": [
{
"key": "./Component",
"outFileName": "Component-HASH.js"
}
],
"shared": [
{
"packageName": "@angular/core",
"outFileName": "angular-core-HASH.js",
"version": "20.1.4",
"requiredVersion": "^20.1.0",
"singleton": true,
"strictVersion": true
}
]
}
Every setting from the config shows up here. version is what this app was built with, and requiredVersion is what it accepts. singleton and strictVersion tell the browser how strict to be. The $version: "v4" marker tells runtimes which manifest format they're reading. With denseChunking turned on, there's also a chunks section listing the split code files.
importmap.json: this app's own view
The build also writes an importmap.json (write-import-map.ts). It maps each shared library to the file that holds it:
{
"imports": {
"@angular/core": "angular-core-HASH.js",
"rxjs": "rxjs-HASH.js"
}
}
The mental model docs describe it as “this project's view of where its externals live.” The key word is this project's. It only knows about one app. The import map that actually runs in the browser is built later, by the runtime, from the remoteEntry.json files of the host and every remote together.
Static and dynamic remotes
This is the part that surprises people coming from webpack Module Federation: a Native Federation host never looks at its remotes during the build. It doesn't fetch them, check them, or link against them. The only question is where the host finds the remote URLs at runtime, and the ng add schematic gives you two choices through --type (Angular adapter getting started).
- Static remotes (
--type host). The remote URLs are written straight intomain.tsas an object. The schematic source generates something like this:
initFederation ({
' mfe1 ': ' http://localhost:4201/remoteEntry.json '
}, { hostRemoteEntry : { url : ' ./remoteEntry.json ' } })
It's simple, but the URLs are now part of your host's code. Moving a remote to a new URL, or deploying the same host to test and production, means rebuilding the host.
- Dynamic remotes (
--type dynamic-host, the recommended default). The URLs live in afederation.manifest.jsonin yourpublic/folder:
{
"mfe1": "http://localhost:4201/remoteEntry.json"
}
main.ts just passes the file name, initFederation('federation.manifest.json', ...), and the manifest is fetched when the app starts. The same host build can run in every environment. Only the manifest changes, and it can even be generated by your deployment pipeline.
Remotes discovered later
Sometimes nobody knows the full list of remotes up front, as in a plugin system where customers enable features. The Orchestrator handles this with initRemoteEntry(), which adds a remote after the app has started (Orchestrator getting started). It only ever adds to the import map and never changes decisions already made.
Two more details from that generated main.ts are worth noticing:
- The host publishes its own
remoteEntry.json. Every generatedmain.tspasseshostRemoteEntry: { url: './remoteEntry.json' }. That's the host's own build output, and at runtime the host's versions win whenever a library appears in both the host and a remote (Orchestrator configuration). - A remote can run on its own. A remote's
main.tscallsinitFederation({}, ...)with an empty list. It acts as its own host, which is why you can open a remote in the browser and work on it alone.
Where version resolution fits
Everything in this article happens at build time, and the build never chooses a version. It only records them: which version each app has, which range it accepts, and how strict it wants to be. The choosing happens in the browser, when the runtime reads all the remoteEntry.json files together. Part 4 looks at how the Classic Runtime made that choice, and why it often ended up loading two copies of Angular. Part 5 shows how the Orchestrator does it properly.
Key takeaways
federation.config.mjsdecides what is shared and how strictly. By default, everything is a strict singleton.skipdoesn't remove a library; it just keeps it out of the shared bundle.
Comments
No comments yet. Start the discussion.