DEV Community

Cover image for Framework-agnostic micro-frontends: contracts over frameworks
owen adira
owen adira

Posted on Originally published at owenadirah.com

Framework-agnostic micro-frontends: contracts over frameworks

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 whole post on one slide: Momotaro (the shell) shares kibi-dango (the contract) with every remote, whatever framework it's built with.

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;
}
Enter fullscreen mode Exit fullscreen mode

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 call getRegistry(). 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'));
Enter fullscreen mode Exit fullscreen mode
// owezzy-shell/public/federation.manifest.json
{
  "garage": "https://garage.owenadirah.com/remoteEntry.json",
  "hras": "https://hras.owenadirah.com/remoteEntry.json"
}
Enter fullscreen mode Exit fullscreen mode

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(...)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 } }];
      });
}
Enter fullscreen mode Exit fullscreen mode
// 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' },
];
Enter fullscreen mode Exit fullscreen mode

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' }),
  },
});
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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])),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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?.();
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Shell foundation. Registry, Native Federation, and session ownership in the shell. Build it, and nothing else, first.
  2. Routing. Decide what each remote exposes (./Routes, a component, or ./mount) and move sub-routes into the remote.
  3. Contracts. Pull auth sharing into the contract package and delete every remote's own copy of token handling.
  4. Features. Move one feature area at a time, keeping each feature standalone and route-driven.
  5. Clean up. Old module patterns, layout libraries, and styling conventions go last once the boundaries hold.
  6. 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 ./Routes or ./mount as 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)

Collapse
 
devsupportss profile image
Dev Supports •

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

‍‍‌