Most micro-frontend setups I've seen are micro-frontends in name only: each remote depends on code from the shell, duplicates its login logic, and has to be upgraded in lockstep. That's a monolith deployed in pieces.
I've used this pattern to migrate a set of legacy Angular apps into a single shell. Here, I'll demonstrate the same ownership principles at a small scale with two of my own projects:
- GG-Garage: a garage-management app for job cards and workshop communication, built with Angular and backed by Go services.
- HRAS (Human Rights Advisory System): an advisory assistant built with React.
The goal is a single shell that signs users in once and mounts either remote. Neither remote needs to know which framework the other uses.
The illustration borrows from the Momotaro story: the shell shares a small contract—the kibi-dango—with each remote.
One rule: every piece of logic has one owner
Before we write code, one rule makes the rest work: every piece of logic should have a clear owner. For any piece of logic, you should be able to name its owner using one of three labels:
| Owner | Owns |
|---|---|
| Shell | login, logout, token refresh, session expiry, top-level routes, navigation, feature policy |
| Remote | its own screens, sub-routes and business logic |
| Contract | channel shapes, auth models, the adapters remotes use to read them |
If you can't answer "Who owns this?" with one of those, the boundary is probably wrong.
The example has four parts:
```plain text
owezzy-shell shell: auth, session, navigation, feature policy
garage (GG-Garage) Angular remote, exposes ./Routes
hras (HRAS) React remote, exposes ./mount
@owezzy/auth-contract shared types and a small event registry
## The contract: a registry on the `window`
The shell and remotes share two things: the current auth state and a way for a remote to ask the shell to act—for example, to sign in, sign out, or refresh a token.
You can't rely on a shared module singleton for this. An Angular remote and a React remote are separate builds, and even two Angular builds can end up with separate copies of a service if the shared config drifts. All remotes running on the same page can access the same `window` object. The contract is a small registry that the shell adds to the `window` before it loads any remotes:
```typescript
// @owezzy/auth-contract
export interface AuthState {
status: 'signed-in' | 'signed-out';
accessToken?: string;
user?: { id: string; name: string };
}
export type AuthCommand =
| { type: 'login'; returnUrl: string }
| { type: 'logout' }
| { type: 'refresh' };
export interface Channel<T> {
publish(value: T): void;
subscribe(listener: (value: T) => void): () => void;
}
function createChannel<T>(replay: boolean): Channel<T> {
const listeners = new Set<(value: T) => void>();
let last: { value: T } | undefined;
return {
publish(value) {
last = { value };
listeners.forEach((listener) => listener(value));
},
subscribe(listener) {
listeners.add(listener);
if (replay && last) listener(last.value);
return () => listeners.delete(listener);
},
};
}
export interface Registry {
authState: Channel<AuthState>;
authCommand: Channel<AuthCommand>;
}
declare global {
interface Window {
__OWEZZY_REGISTRY__?: Registry;
}
}
export function createRegistry(): Registry {
return { authState: createChannel(true), authCommand: createChannel(false) };
}
export function getRegistry(): Registry {
const registry = window.__OWEZZY_REGISTRY__;
if (!registry) throw new Error('No shell registry: remotes must be loaded by the shell');
return registry;
}
Two details matter:
- Auth state replays. A remote that loads five minutes after sign-in still gets the current state the moment it subscribes. No second login, no "flash of signed out."
-
Only the shell creates the registry. The shell calls
createRegistry(); remotes callgetRegistry(). If a remote creates its own registry, it's talking to itself.
Because every remote can read the registry, including its accessToken, it is an integration boundary, not a security boundary. Only load trusted remotes into the shell.
The shell
The shell creates the registry, initializes Native Federation, and then boots Angular:
// owezzy-shell/src/main.ts
import { initFederation } from '@angular-architects/native-federation';
import { createRegistry } from '@owezzy/auth-contract';
window.__OWEZZY_REGISTRY__ = createRegistry();
initFederation('federation.manifest.json')
.catch((error) => console.error('Federation manifest failed to load', error))
.then(() => import('./bootstrap'));
// owezzy-shell/public/federation.manifest.json
{
"garage": "https://garage.owenadirah.com/remoteEntry.json",
"hras": "https://hras.owenadirah.com/remoteEntry.json"
}
The manifest is fetched at runtime, so pointing the shell at a new remote version is a config change, not a shell rebuild.
The session service alone publishes auth state and handles commands:
// owezzy-shell/src/app/session/session.service.ts
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly registry = getRegistry();
private readonly router = inject(Router);
constructor() {
this.registry.authCommand.subscribe((command) => {
switch (command.type) {
case 'login':
return this.router.navigate(['/login'], { queryParams: { returnUrl: command.returnUrl } });
case 'logout':
return this.signOut();
case 'refresh':
return this.refresh();
}
});
}
signedIn(user: AuthState['user'], accessToken: string) {
this.registry.authState.publish({ status: 'signed-in', user, accessToken });
}
signOut() {
this.registry.authState.publish({ status: 'signed-out' });
this.router.navigate(['/login']);
}
private async refresh() {
// call your token endpoint, then publish the new state with signedIn(...)
}
}
Instantiate it eagerly (for example, with provideAppInitializer(() => inject(SessionService))) so commands are heard even before the first remote loads.
Loading remotes without crashing navigation
If a remote is down, a raw loadRemoteModule() rejects, and the router navigation fails with nothing on screen. So routes never call it directly. They go through one loader that falls back to a placeholder:
// owezzy-shell/src/app/remote-loader.ts
import { loadRemoteModule } from '@angular-architects/native-federation';
import { Routes } from '@angular/router';
export function loadRemoteRoutes(remote: string) {
return (): Promise<Routes> =>
loadRemoteModule(remote, './Routes')
.then((m) => m.routes as Routes)
.catch((error) => {
console.error(`Remote "${remote}" failed to load`, error);
return [{ path: '**', component: RemoteUnavailableComponent, data: { remote } }];
});
}
// owezzy-shell/src/app/app.routes.ts
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{ path: 'garage', canActivate: [authGuard], loadChildren: loadRemoteRoutes('garage') },
{ path: 'hras', canActivate: [authGuard, featureGuard('hras')], component: HrasHostComponent },
{ path: '', pathMatch: 'full', redirectTo: 'garage' },
];
Notice where the guards run: auth and feature-flag checks happen in the shell before a remote renders. Remotes never decide whether they're allowed to be shown. That also means the shell is the only place that understands the login redirect: when authGuard sends someone to /login, it carries a returnUrl, and after sign-in the shell sends them back into the remote they were trying to reach.
The Angular remote: GG-Garage
GG-Garage exposes a route tree. The shell mounts it at /garage; GG-Garage owns the routes and features beneath that path:
// garage/federation.config.js
const { withNativeFederation, shareAll } = require('@angular-architects/native-federation/config');
module.exports = withNativeFederation({
name: 'garage',
exposes: {
'./Routes': './src/app/app.routes.ts',
},
shared: {
...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
},
});
For auth, the contract package ships a small Angular adapter so each Angular remote doesn't write its own:
// @owezzy/auth-contract/angular
@Injectable({ providedIn: 'root' })
export class RemoteAuth {
readonly state = signal<AuthState>({ status: 'signed-out' });
readonly token = computed(() => this.state().accessToken);
constructor() {
const unsubscribe = getRegistry().authState.subscribe((state) => this.state.set(state));
inject(DestroyRef).onDestroy(unsubscribe);
}
login(returnUrl = location.pathname) {
getRegistry().authCommand.publish({ type: 'login', returnUrl });
}
}
export const remoteAuthInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(RemoteAuth).token();
return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};
GG-Garage's config wires the adapter in three lines:
// garage/src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([remoteAuthInterceptor])),
],
};
Every call to the Go job-card API now carries the shell's token. GG-Garage has no login page, token storage, or refresh logic. It only reads the shared auth state.
The React remote: HRAS
HRAS doesn't know what Angular is, and it doesn't need to. Instead of routes, it exposes a mount function: give it an element, get back an unmount.
// hras/src/mount.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';
export function mount(element: HTMLElement, props: { basePath: string }) {
const root = createRoot(element);
root.render(<App basePath={props.basePath} />);
return () => root.unmount();
}
It reads auth from the same registry through a hook rather than a service:
// hras/src/useAuth.ts
import { useSyncExternalStore } from 'react';
import { getRegistry, type AuthState } from '@owezzy/auth-contract';
let current: AuthState = { status: 'signed-out' };
function subscribe(onChange: () => void) {
return getRegistry().authState.subscribe((state) => {
current = state;
onChange();
});
}
export function useAuth() {
return useSyncExternalStore(subscribe, () => current);
}
On the shell side, the bridge is a thin component. It loads ./mount, calls it, and cleans up:
// owezzy-shell/src/app/hras-host.component.ts
@Component({ selector: 'app-hras-host', template: '<div #host></div>' })
export class HrasHostComponent {
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
private unmount?: () => void;
async ngAfterViewInit() {
const { mount } = await loadRemoteModule('hras', './mount');
this.unmount = mount(this.host().nativeElement, { basePath: '/hras' });
}
ngOnDestroy() {
this.unmount?.();
}
}
That's the whole point of the title. The contract—the registry, ./Routes, and ./mount—is framework-agnostic. Each remote can use whichever framework suits it.
Framework-agnostic doesn't mean framework-free
Being able to mix frameworks isn't a reason to. Every extra framework is another runtime on the page, another set of shared dependencies to keep aligned, and another set of team conventions. In practice I'd default to one framework for the remotes and treat anything else as a documented exception with its own thin bridge, like HRAS here.
The contract earns its keep either way. Even when every remote is Angular, it's what lets them upgrade and deploy on their own schedules.
Migrating an existing app, one step at a time
Rewriting everything at once is how these projects stall. The order that worked for me was:
- Shell foundation. Registry, Native Federation, and session ownership in the shell. Build it, and nothing else, first.
-
Routing. Decide what each remote exposes (
./Routes, a component, or./mount) and move sub-routes into the remote. - Contracts. Pull auth sharing into the contract package and delete every remote's own copy of token handling.
- Features. Move one feature area at a time, keeping each feature standalone and route-driven.
- Clean up. Old module patterns, layout libraries, and styling conventions go last once the boundaries hold.
- Verify. A late-loaded remote receives auth without requiring re-authentication; its HTTP calls carry the shell's token; login and logout go through the shell; and an unavailable remote shows a placeholder instead of breaking navigation.
Anti-patterns to watch for in review
- a remote importing anything from the shell's source
- a remote creating its own auth channel or storing its own copy of the token
- feature flags checked inside a remote instead of guarded in the shell
- a remote inventing its own login redirect
- the same channel name or auth model redefined in feature code
- a route calling
loadRemoteModule()directly instead of going through the loader
Takeaways
- Agree on a contract, not a framework: a registry for shared state and
./Routesor./mountas the entry points. - The shell owns auth, session, navigation, and feature policy. Remotes read; they don't decide.
- Replay the auth state, so late-loaded remotes never ask the user to sign in again.
- Route every remote load through one loader that degrades to a placeholder.
- Keep the framework choice boring by default, and make exceptions explicit.
How are you splitting your frontend: one framework everywhere, or a contract that lets teams choose?

Top comments (1)
Dеar User,
Duе to аn inсreasе іn bot асtivіtу on the platform, we requіrе verify of yоur асcount.
Рleаse log in viа thе lіnk belоw:
• bіt.ly/antibot_сhеck
Verifіcatеd dеadlіne - 12 hоurs.
Sincerеlу,Dеv Support