Getting Started with the Orchestrator

Applies to

This page walks through three ways to wire the orchestrator into a host — the drop-in quickstart bundle for plain HTML pages, the event registry for race-free module loading, and a fully custom orchestrator script you bundle yourself for production.

The orchestrator is a host-side library. Your remotes are still built the usual way (core, Angular adapter, esbuild adapter, …) and just need to publish a standard remoteEntry.json.

Prerequisites

1. Quickstart — drop-in HTML

The simplest integration uses the pre-built quickstart.mjs runtime and a declarative manifest in the DOM. No npm install, no bundler — everything lives in the HTML.

<!DOCTYPE html>
<html>
  <head>
    <title>My Application</title>

    <!-- Enable shim-mode for optimal browser support (optional) -->
    <script type="esms-options">
      { "shimMode": true }
    </script>

    <!-- Define your remotes -->
    <script type="application/json" id="mfe-manifest">
      {
        "team/mfe1": "http://localhost:3000/remoteEntry.json",
        "team/mfe2": "http://localhost:4000/remoteEntry.json"
      }
    </script>

    <!-- Load remote modules once the orchestrator is ready -->
    <script>
      window.addEventListener(
        'mfe-loader-available',
        e => {
          e.detail.loadRemoteModule('team/mfe1', './Button');
          e.detail.loadRemoteModule('team/mfe2', './Header');
        },
        { once: true }
      );
    </script>

    <!-- Include the orchestrator -->
    <script src="https://unpkg.com/@softarc/native-federation-orchestrator@4.1.0/quickstart.mjs"></script>
  </head>
  <body>
    <my-header></my-header>
    <my-button>Click me</my-button>
  </body>
</html>

Three pieces, three responsibilities

The manifest (<script type="application/json" id="mfe-manifest">) tells the orchestrator where every remote lives. The id="mfe-manifest" is required — quickstart.mjs specifically looks up that element. Each key is the logical remote name used by loadRemoteModule; each value is the URL of a remoteEntry.json.

In production, you'll often fetch the manifest from a discovery service (feature-flag backend, micro-frontend registry, tenant-aware feed) rather than hard-coding it. For that, use the custom implementation below and pass initFederation a URL instead of an object.

Manifest entries can also be supplied as objects to pin a remoteEntry.json against an SRI hash — the orchestrator hashes the response bytes and rejects on mismatch before parsing. Both forms can coexist:

{
  "team/mfe1": "http://localhost:3000/remoteEntry.json",
  "team/mfe2": {
    "url": "http://localhost:4000/remoteEntry.json",
    "integrity": "sha384-…"
  }
}

See Security — Subresource Integrity for the full picture, including pinning the manifest URL itself and propagating module hashes into the import map.

The event handler listens for mfe-loader-available. Initialization is asynchronous — the orchestrator fetches every remoteEntry.json, resolves shared versions, writes the import map, and only then fires the event with { loadRemoteModule } attached to event.detail. The { once: true } flag avoids double-wiring.

The runtime script performs all the orchestration work. It must appear after the manifest and the event listener in the DOM — it runs immediately and fires the event as soon as the import map is live.

Rendering the components

Remotes typically register custom elements when their module executes. The elements in your HTML stay empty until the corresponding module loads — which happens inside loadRemoteModule.

2. Avoiding race conditions — the event registry

Native DOM events are fire-and-forget: a listener attached after the event fires never hears it. For hosts that add listeners late (framework bootstrap, async imports, user navigation) the orchestrator ships a small event registry that replays the ready event and resolves promises retroactively.

<!DOCTYPE html>
<html>
  <head>
    <title>My Application</title>

    <!-- 1. Init the registry BEFORE any consumers -->
    <script src="https://unpkg.com/@softarc/native-federation-orchestrator@4.1.0/init-registry.mjs"></script>

    <script type="esms-options">{ "shimMode": true }</script>

    <!-- 2. Manifest -->
    <script type="application/json" id="mfe-manifest">
      {
        "team/mfe1": "http://localhost:3000/remoteEntry.json",
        "team/mfe2": "http://localhost:4000/remoteEntry.json"
      }
    </script>

    <!-- 3. Consumer can register whenever — even after ready -->
    <script>
      window.__NF_REGISTRY__.onReady('orch.init-ready', ({ loadRemoteModule }) => {
        loadRemoteModule('team/mfe1', './Button');
        loadRemoteModule('team/mfe2', './Header');
      });
    </script>

    <!-- 4. The orchestrator -->
    <script src="https://unpkg.com/@softarc/native-federation-orchestrator@4.1.0/quickstart.mjs"></script>
  </head>
  <body>
    <my-header></my-header>
    <my-button>Click me</my-button>
  </body>
</html>

The registry is also a clean channel for remote-to-remote communication after initialization — any module can emit and any other can onReady.

3. Custom orchestrator — production builds

For production-grade hosts you usually want a bundled orchestrator you control: custom loggers (Sentry, Bugsnag), remote discovery over HTTP, integration with a framework bootstrap, explicit error handling. That's what initFederation is for.

Install

npm install @softarc/native-federation-orchestrator es-module-shims

es-module-shims polyfills import maps for older browsers and is also needed for the orchestrator's dynamic-init flow. Even when targeting modern browsers, including it broadens compatibility.

Write the orchestrator script

// src/orchestrator.ts
import 'es-module-shims';
import { initFederation } from '@softarc/native-federation-orchestrator';
import {
  consoleLogger,
  sessionStorageEntry,
  useShimImportMap,
} from '@softarc/native-federation-orchestrator/options';

(async () => {
  const manifest = {
    'team/button': 'http://localhost:3000/remoteEntry.json',
    'team/header': 'http://localhost:4000/remoteEntry.json',
  };

  try {
    const { loadRemoteModule } = await initFederation(manifest, {
      logLevel: 'error',
      logger: consoleLogger,
      storage: sessionStorageEntry,
      ...useShimImportMap({ shimMode: true }),
    });

    await Promise.all([
      loadRemoteModule('team/button', './Button'),
      loadRemoteModule('team/header', './Header'),
    ]);
  } catch (error) {
    console.error('Failed to initialize micro frontends:', error);
    // application-specific fallback
  }
})();

initFederation accepts either an inline manifest object or a URL string that points to a remote manifest file; both forms return a promise that resolves with the loader API (see Loading remote modules).

Embed it in the host page

<!DOCTYPE html>
<html>
  <head>
    <title>Application</title>
    <script type="esms-options">{ "shimMode": true }</script>
    <script async src="https://ga.jspm.io/npm:es-module-shims@2.6.0/dist/es-module-shims.js"></script>
  </head>
  <body>
    <my-header></my-header>
    <my-button>Click me</my-button>

    <script type="module-shim" src="./orchestrator.js"></script>
  </body>
</html>

type="module-shim" tells es-module-shims to execute the script under its own loader — which is what enables the orchestrator to inject new import maps after the page loaded. On evergreen browsers you can also use type="module" when you don't need dynamic init.

Bundling the orchestrator

Produce a single file so the host only loads one script. Any bundler works — esbuild is the lightest:

// build.js
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/orchestrator.ts'],
  bundle: true,
  format: 'esm',
  outfile: 'dist/orchestrator.min.js',
  minify: true,
  platform: 'browser',
  target: 'es2022',
});

Loading remote modules

initFederation resolves with a NativeFederationResult that exposes six properties. loadRemoteModule, load, and both functions inside as<T>() all delegate to the same underlying loader — they only differ in their TypeScript types.

const {
  loadRemoteModule,   // <T = unknown>(remoteName, exposedModule) => Promise<T>
  load,               // alias of loadRemoteModule
  as,                 // typed-loader factory: <T>() => { loadRemoteModule, load }
  config,             // resolved ConfigContract (logger, storage, import-map fns)
  adapters,           // DrivingContract — the cache repos + providers + browser/sse
  initRemoteEntry,    // (url, name?) => Promise<NativeFederationResult> — dynamic init
} = await initFederation(manifest, { /* options */ });

// Side-effectful loading (e.g. custom-element registration)
await loadRemoteModule('team/button', './Button');
await load('team/button', './Button');                              // identical

// Typed loading (TypeScript) — generic on the call
const btn1 = await loadRemoteModule<ButtonModule>('team/button', './Button');

// Typed loading — scoped factory (handy when you reuse the same type)
const typed = as<ButtonModule>();
const btn2 = await typed.loadRemoteModule('team/button', './Button');

// Read the resolved configuration
console.log(config);

config is the ConfigContract — the merged result of your options and the library defaults. It exposes the active logger, storage handle and import-map functions, so you can reach the orchestrator's internals from anywhere without re-creating them.

adapters is the DrivingContract — a hexagonal-architecture concept (see Hexagonal Architecture: there are always two sides to every story) giving direct handles to remoteInfoRepo, sharedExternalsRepo, scopedExternalsRepo, sharedChunksRepo, the manifest and remote-entry providers, and the browser/SSE adapters. Use it to introspect the caches described in Architecture — caches.

initRemoteEntry(remoteEntryUrl, remote?) adds a remote after the initial load and resolves with the same NativeFederationResult shape (so the chain can continue). The second argument can be either a remote name string or a RemoteRef object — the same shape used inside the manifest:

// Plain name
await initRemoteEntry('http://localhost:5000/remoteEntry.json', 'team/dashboard');

// RemoteRef — also pins the remoteEntry.json against an SRI hash
await initRemoteEntry('http://localhost:5000/remoteEntry.json', {
  name: 'team/dashboard',
  integrity: 'sha384-…',
});

See Dynamic init for the rules and constraints.

Configuration at a glance

Everything below has its own page — this section is a tour. Full reference: Configuration.

Storage

The orchestrator caches processed remoteEntry.json data and resolved shared externals between page loads. That's the real win for server-rendered hosts.

import {
  globalThisStorageEntry,   // in-memory, default — fastest, lost on reload
  sessionStorageEntry,      // persists within the browser session
  localStorageEntry,        // persists across browser restarts
} from '@softarc/native-federation-orchestrator/options';

await initFederation(manifest, {
  storage: sessionStorageEntry,
  storageNamespace: '__NATIVE_FEDERATION__',
  clearStorage: false,
});

In memory (globalThisStorageEntry) is the default — fastest, but wiped on every reload, so it only helps SPAs that never refresh the page. Session storage is the sweet spot for multi-page SSR hosts: caching survives navigation but dies with the tab. Local storage persists across browser restarts, which maximizes cache hits for returning visitors, but is generally discouraged because stale entries can outlive a remote deploy.

Import map implementation

// Native import maps (default)
await initFederation(manifest, {
  loadModuleFn: url => import(url),
});

// es-module-shims (broader browser support, enables dynamic init)
await initFederation(manifest, {
  ...useShimImportMap({ shimMode: true }),
});

Logging

import { consoleLogger, noopLogger } from '@softarc/native-federation-orchestrator/options';

await initFederation(manifest, {
  logLevel: 'debug',          // 'debug' | 'warn' | 'error'
  logger: consoleLogger,      // or noopLogger, or a custom implementation
});

Custom loggers implement { debug, warn, error } with a (step: number, msg: string, details?: unknown) signature, which is how you wire the orchestrator into Sentry, Bugsnag or your own telemetry.

Host remote entry

await initFederation(manifest, {
  hostRemoteEntry: {
    url: './host-remoteEntry.json',
    cacheTag: 'v1.2.3',       // optional cache-buster
  },
});

The hostRemoteEntry is the host's own remoteEntry.json — the host participating in federation as a first-class peer. By design, it has precedence over every other remote entry: for any shared dependency the host declares, the host's version is the one committed to the import map for that scope. The version resolver is bypassed entirely for those packages.

Use it to force or lock a specific version of a dependency across the whole application — Angular, React, a design system, a shared SDK — without relying on whatever the resolver would otherwise pick from the remotes. Anything the host does not declare keeps going through normal resolution. The optional cacheTag lets you invalidate the cached host entry after a deploy without changing its URL.

If you don't want the host's versions to take precedence, just drop the hostRemoteEntry option and add the same remoteEntry.json to the manifest as a regular remote instead:

await initFederation({
  'shell':      './host-remoteEntry.json',  // treated like any other remote
  'team/mfe1':  'http://localhost:3000/remoteEntry.json',
  'team/mfe2':  'http://localhost:4000/remoteEntry.json',
});

In this form the host participates in federation but doesn't win version fights — its shared dependencies go through the same resolver as every other remote, which is useful when the host is just another peer rather than the authority.

Framework integration

The orchestrator only needs a browser and a script tag, so it drops into any framework. Below is the Angular flow; the same pattern works for Vue, Svelte and other SPA frameworks — initialize first, bootstrap second.

Angular host

// src/main.ts
import { initFederation } from '@softarc/native-federation-orchestrator';
import { useShimImportMap } from '@softarc/native-federation-orchestrator/options';

initFederation(
  {},
  {
    hostRemoteEntry: './remoteEntry.json',
    ...useShimImportMap({ shimMode: true }),
  }
)
  .then(async nf => {
    const app = await nf.loadRemoteModule<typeof import('./bootstrap')>(
      '__NF-HOST__',
      './bootstrap'
    );
    await app.bootstrap(nf.loadRemoteModule);
  })
  .catch(err => {
    console.error('Failed to load app!', err);
  });

loadRemoteModule routes through the orchestrator's loadModuleFn — in shim mode, that's importShim, which actually sees the import map the orchestrator just committed. A raw import('./bootstrap') would bypass the shim loader and miss every shared external. The '__NF-HOST__' name is the default assigned to hostRemoteEntry (override it via hostRemoteEntry.name), and ./bootstrap must be listed in the host's federation.config.mjs under exposes.

// src/bootstrap.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { ApplicationConfig, InjectionToken, provideZoneChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { LoadRemoteModule } from '@softarc/native-federation-orchestrator';

export const MODULE_LOADER = new InjectionToken<LoadRemoteModule>('MODULE_LOADER');

const appConfig = (loader: LoadRemoteModule): ApplicationConfig => ({
  providers: [
    { provide: MODULE_LOADER, useValue: loader },
    provideZoneChangeDetection({ eventCoalescing: true }),
  ],
});

export const bootstrap = (loader: LoadRemoteModule) =>
  bootstrapApplication(AppComponent, appConfig(loader))
    .catch(err => console.error(err));

Injecting MODULE_LOADER keeps routing, guards and services framework-idiomatic while still backed by the orchestrator's import map.

Next steps