| name | create-mfe |
| description | Add a new micro-frontend (MFE) to this workspace. Use when asked to create a new remote app, expose a feature as a micro-frontend, or wire up a new remote to the host. Covers vite.config setup, shared singletons, NG0912 prevention, test stubs, CI/deployment, and E2E.
|
Create a Micro-Frontend (MFE)
This workspace uses @module-federation/vite with @analogjs/platform (Analog.js)
on top of Nx. The host is apps/web-app (port 4200). Additional remotes follow
the pattern of apps/counter-remote (port 4201+).
Architecture overview
apps/
web-app/ ← host (Analog SPA, port 4200)
counter-remote/ ← remote (Analog SPA, port 4201)
libs/
counter/ ← workspace lib exposed by counter-remote
The remote exposes one route file (./Routes). The host lazy-loads it
via loadChildren. The two apps share Angular, CDK, Material, NgRx, and RxJS as
MF singletons so only one copy of each runs in the browser.
Step 0 – Generate the remote app
pnpm nx generate @analogjs/platform:app my-remote --directory=apps/my-remote
Replace the generated vite.config.ts with the template in Step 3. Keep the
generated app shell files (src/main.ts, src/app/app.ts,
src/app/app.config.ts, src/app/app.routes.ts) — the remote needs these to
run as a standalone app via nx serve and in Playwright. Wire app.routes.ts
to spread the feature routes and add a wildcard fallback:
import { Route } from '@angular/router';
import { myFeatureRoutes } from '@myorg/my-feature';
export const routes: Route[] = [...myFeatureRoutes, { path: '**', redirectTo: '' }];
Delete generated pages/routes you don't need — the remote exposes its feature
via remote-routes.ts, not via Analog file-based routing.
Step 1 – Create the workspace lib
Generate the feature lib if it doesn't exist:
pnpm nx generate @nx/angular:library my-feature --directory=libs/my-feature --standalone
Create libs/my-feature/src/lib/lib.routes.ts. Use loadComponent so the
component is still lazy-loaded by Angular's router:
import { Route } from '@angular/router';
export const myFeatureRoutes: Route[] = [
{
path: '',
title: 'My Feature',
loadComponent: () => import('./my-feature/my-feature').then((m) => m.MyFeature),
providers: [MyFeatureStore],
},
];
Export the routes from the lib's barrel (libs/my-feature/src/index.ts):
export * from './lib/lib.routes';
Components do not need to be in the barrel unless something outside the lib
imports them directly. The routes are all that the remote entry point and the
test stub need.
Step 2 – Install the MF package (if not already present)
pnpm add -D @module-federation/vite
@module-federation/vite is a build-time bundler plugin — it belongs in
devDependencies.
Step 3 – Configure the remote's vite.config.ts
Copy apps/counter-remote/vite.config.ts as your starting point. After copying,
update these remote-specific values:
| Field | Example |
|---|
cacheDir | ../../node_modules/.vite/my-remote |
build.outDir | ../../dist/apps/my-remote |
server.port / server.origin | next available port, e.g. 4202 |
federation({ name: ... }) | 'my-remote' |
federation({ exposes: ... }) | './Routes': './src/remote-routes.ts' |
Two required workaround plugins (always include both)
{
name: 'normalize-server-watch',
enforce: 'pre' as const,
config: () => ({ server: { watch: {} } }),
},
{
name: 'virtual-pwa-register-stub',
resolveId: (id: string) =>
id === 'virtual:pwa-register' ? '\0virtual:pwa-register' : undefined,
load: (id: string) =>
id === '\0virtual:pwa-register'
? 'export const registerSW = () => () => {};'
: undefined,
},
Disable federation in test mode
mode !== 'test' &&
federation({
name: 'my-remote',
filename: 'remoteEntry.js',
dts: false,
exposes: {
'./Routes': './src/remote-routes.ts',
},
shared: sharedDeps,
}),
Federation must be disabled in mode === 'test' — the MF virtual modules break
vitest's module resolver.
External pwa-register from the build
build: {
rolldownOptions: {
external: ['virtual:pwa-register'],
},
},
sharedDeps for the remote — import: false on CDK/Material
Critical: Angular CDK and Material packages must have import: false on the
remote (not on the host). Without this, @module-federation/vite generates a
loadShare virtual module with a top-level import * as __mfLocalShare from '@angular/material/button'. This eagerly evaluates the module from the remote's
dev server (a different URL), causing Angular to register the same component class
twice → NG0912 collisions at runtime.
With import: false, MF generates a deferred-export module that reads Material
from the host's shared scope (__mfModuleCache) instead of loading its own copy.
const angVer = '~21.2.15';
const cdkMatVer = '~21.2.13';
const sharedDeps = {
'@angular/animations': { singleton: true, requiredVersion: angVer },
'@angular/common': { singleton: true, requiredVersion: angVer },
'@angular/common/http': { singleton: true, requiredVersion: angVer },
'@angular/compiler': { singleton: true, requiredVersion: angVer },
'@angular/core': { singleton: true, requiredVersion: angVer },
'@angular/forms': { singleton: true, requiredVersion: angVer },
'@angular/platform-browser': { singleton: true, requiredVersion: angVer },
'@angular/platform-browser/animations': { singleton: true, requiredVersion: angVer },
'@angular/platform-browser-dynamic': { singleton: true, requiredVersion: angVer },
'@angular/router': { singleton: true, requiredVersion: angVer },
'@angular/cdk/a11y': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/bidi': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/layout': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/observers': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/overlay': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/portal': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/scrolling': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/cdk/text-field': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/badge': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/bottom-sheet': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/button': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/checkbox': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/core': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/divider': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/form-field': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/icon': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/input': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/list': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/paginator': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/progress-spinner': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/sidenav': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/snack-bar': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/table': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/toolbar': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@angular/material/tooltip': { singleton: true, requiredVersion: cdkMatVer, import: false },
'@ngrx/signals': { singleton: true, requiredVersion: '~21.1.0' },
'@ngrx/signals/events': { singleton: true, requiredVersion: '~21.1.0' },
rxjs: { singleton: true, requiredVersion: '~7.8.2' },
tslib: { singleton: true, requiredVersion: '~2.8.1' },
};
Add only the CDK/Material sub-paths your remote actually uses. If the
remote later adds more Material imports, add their sub-paths here too.
Do NOT add @myorg/* workspace libs to the shared config. MF uses Rolldown
to build loadShare virtual modules, and Rolldown cannot enumerate export *
chains from TypeScript path aliases. This causes [MISSING_EXPORT] build
errors at runtime. Workspace libs should be bundled into the remote directly.
Use sub-paths, not root paths. Declaring '@angular/material': { ... }
(no trailing slash) only matches the exact bare specifier. It does NOT match
@angular/material/button. You must list each sub-path explicitly.
Step 4 – Configure the host's vite.config.ts
Add the remote to the host's federation config. The host's sharedDeps does
not need import: false — the host is the provider of these modules.
mode !== 'test' &&
federation({
name: 'host',
filename: 'remoteEntry.js',
dts: false,
remotes: {
'counter-remote': { },
'my-remote': {
type: 'module',
name: 'my-remote',
entry:
process.env['MY_REMOTE_ENTRY'] ??
'http://localhost:4202/remoteEntry.js',
entryGlobalName: 'my-remote',
shareScope: 'default',
},
},
exposes: {},
shared: mfeSharedDeps,
}),
The MY_REMOTE_ENTRY env var lets CI inject the production URL (see Step 8).
Step 5 – Create the remote's entry point
export { myFeatureRoutes } from '@myorg/my-feature';
Step 6 – Add the route to the host app
{
path: 'my-feature',
loadChildren: () =>
import('my-remote/Routes').then((m) => m.myFeatureRoutes),
},
Add a TypeScript module declaration so the import type-checks:
declare module 'my-remote/Routes' {
import type { Route } from '@angular/router';
export const myFeatureRoutes: Route[];
}
Step 7 – Add a navigation link
{ routerLink: '/my-feature', label: 'My Feature', icon: 'hub' },
Step 8 – Integration tests: fastCompile + test stub
Enable fastCompile on the host for test mode
MFE remote files loaded through import('my-remote/Routes') are not in
tsconfig.spec.json's TypeScript program. The standard Angular Vite plugin
cannot compile them, causing @analogjs/vitest-angular-sourcemap-plugin to fall
through to OXC in JS mode — which fails on TypeScript syntax like readonly,
type generics, or export type.
Fix: enable fastCompile: mode === 'test' in the host's analog() call.
analog({
ssr: false,
static: true,
fastCompile: mode === 'test',
prerender: { routes: [] },
}),
fastCompile skips Angular's full template type-checking. Template errors
still appear in the IDE and tsc — just not at vitest run time.
Create a test stub for the MFE route
The MFE's remoteEntry.js isn't available in vitest. Replace it with a local
stub via a Vite alias.
Preferred approach — re-export the lib's real routes:
export { myFeatureRoutes } from '@myorg/my-feature';
This is the simplest and most correct stub. It uses the same routes the remote
uses, so integration tests exercise the real component without duplicating route
definitions.
Do NOT import the component directly unless it is in the lib's barrel
(index.ts). An undefined component causes NG04014: Invalid configuration of route at test runtime. Always check what libs/my-feature/src/index.ts
exports before importing from it.
Wire the alias in the host's vite.config (test mode only)
import { resolve } from 'path';
resolve: {
alias:
mode === 'test'
? {
'counter-remote/Routes': resolve(__dirname, 'src/test-stubs/counter-remote-routes.ts'),
'my-remote/Routes': resolve(__dirname, 'src/test-stubs/my-remote-routes.ts'),
}
: {},
},
Step 9 – CI/preview and production deployment
Build order: both apps first, then copy
The remote's output must be nested inside the host's output directory so it's
served from the same origin. Always copy after both builds are done — the
host build wipes and recreates its entire output directory, so any files copied
in before that step will be lost.
- name: Build my-remote (production)
run: pnpm nx build my-remote --configuration production
- name: Build web app
env:
COUNTER_REMOTE_ENTRY: /counter-remote/remoteEntry.js
MY_REMOTE_ENTRY: /my-remote/remoteEntry.js
run: pnpm nx build web-app --configuration production
- name: Copy my-remote output into web-app output
run: |
mkdir -p dist/apps/web-app/client/my-remote
cp -r dist/apps/my-remote/* dist/apps/web-app/client/my-remote/
SWA routing config
Azure Static Web Apps rewrites unknown paths to index.html by default, which
breaks remote asset requests. Add the remote's directory to navigationFallback.exclude.
Important: Azure SWA allows at most one * per path segment. Using **
is invalid and causes deployment to fail with a validation error. Use two
separate entries per remote:
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/*.{css,js,png,gif,ico,jpg,svg,webmanifest,woff,woff2,txt}", "/counter-remote/*", "/counter-remote/assets/*", "/my-remote/*", "/my-remote/assets/*"]
}
}
Step 10 – E2E tests
Add the remote's dev server to Playwright's webServer list. Remotes must
appear before the host entry. Keep the existing API and counter-remote
entries.
webServer: [
{
command: 'npx nx run api:serve',
url: 'http://localhost:60253/health/live',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
{
command: 'npx nx run counter-remote:serve',
url: 'http://localhost:4201/remoteEntry.js',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
{
command: 'npx nx run my-remote:serve',
url: 'http://localhost:4202/remoteEntry.js',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
{
command: 'npx nx run web-app:serve-e2e',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
],
In E2E specs, navigate to the route path defined in the host (e.g. /my-feature).
Step 11 – Dev workflow
Start both apps in separate terminals:
pnpm nx serve my-remote
pnpm nx serve web-app
The host references the remote at http://localhost:4202/remoteEntry.js. If the
remote isn't running the host still loads — the MFE route just fails to activate.
Step 12 – Verify
pnpm nx run-many -t build
pnpm nx run-many -t test
pnpm e2e
Open http://localhost:4200/my-feature — the MFE should load with zero NG0912
warnings in the browser console.
Pitfall reference
| Symptom | Root cause | Fix |
|---|
NG0912 for Material/CDK components | Remote's loadShare virtual module has a top-level import that double-evaluates modules | Add import: false to all CDK/Material entries in the remote's sharedDeps |
NG0912 for workspace lib components | Workspace lib bundled into both host and remote | Remove the workspace lib from MF shared config; bundle it into the remote directly |
[MISSING_EXPORT] "SomeExport" build error | @myorg/* workspace lib added to MF shared config; Rolldown can't enumerate export * from TS path aliases | Never put @myorg/* libs in MF shared config |
SyntaxError: Unexpected identifier / [PARSE_ERROR] Missing initializer in vitest | Files reachable only via dynamic MFE import are not in tsconfig.spec.json's program; OXC falls through to JS mode and chokes on TypeScript syntax | Add fastCompile: mode === 'test' to analog() in the host's vite.config.ts |
NG04014: Invalid configuration of route in vitest | Test stub imports a component not exported from the lib's barrel → undefined in route component field | Re-export the lib's real routes: export { myFeatureRoutes } from '@myorg/my-feature' |
| Remote files missing from preview / production deploy | Copy step ran before host build; host build wiped the output directory | Build both apps first, then copy the remote output into the host's dist folder |
Remote assets return index.html in Azure SWA | navigationFallback rewrites all unknown paths | Add /my-remote/* AND /my-remote/assets/* (two separate entries); never use /** — SWA only allows one * per segment |
Cannot read properties of undefined (reading 'watch') on nx serve | @module-federation/vite crashes when server.watch is false (Vite 8 + Nx default) | Add the normalize-server-watch pre-enforce plugin to both host and remote |
virtual:pwa-register import error in remote | VitePWA only runs in the host, not the remote | Add the virtual-pwa-register-stub plugin to the remote's vite.config |