DEV Community

Cover image for Micro-Frontends: How Multiple Apps Share Code and Communicate
Almas Hossain
Almas Hossain

Posted on

Micro-Frontends: How Multiple Apps Share Code and Communicate

One thing that makes micro-frontends confusing is that developers use the word "sharing" to describe several completely different problems:

  • Sometimes they mean sharing a UI component from another team's app at runtime.
  • Sometimes they mean sharing live application state (like the currently selected user or student) across boundaries.
  • Sometimes they mean sharing common source code (like a design system or utility library) without copy-pasting it across repositories.

These are fundamentally different problems, and they require different architectural solutions.

Let's break them down using a practical, real-world architecture: an Angular-based School Management System. We will look at how a Shell application dynamically loads independently deployed micro-frontends (Student and Fee), how those applications communicate without becoming tightly coupled, how shared libraries work at build time versus runtime, and how to choose the right patterns for production.

1. First, What Problem Do Micro-Frontends Solve?
Imagine you are building a School Management System with Angular. Initially, you start with a single monolithic frontend repository containing everything:

  • Student admission and profiles
  • Fee collection, invoicing, and payment history
  • Daily attendance tracking
  • Examination results and grading
  • Financial reports and analytics At first, a single codebase is fast and simple. Wait until your engineering org scales to multiple squads, however, and friction appears: a minor bug fix in the Fee invoice module requires running the test suite and deployment pipeline for the entire platform, and teams step on each other's merges.

To give teams independent release cycles, you can split the frontend into separate applications aligned with business domains:


Here, Shell provides the outer application chrome and top-level routing. Student owns all student-lifecycle features, and Fee owns all financial workflows. To the end user (a school administrator), navigating from the Student directory to a Fee invoice feels like moving through a single, cohesive Single-Page Application (SPA).

Separating the codebase into independent builds immediately creates three architectural questions:

  1. Runtime Loading: How does shell-app load code from student-app and fee-app when they are deployed on different servers?
  2. Cross-App Communication: When a user selects a student in student-app, how does fee-app know which student's invoices to load?
  3. Code Reuse: How can all three applications share the same UI buttons, modals, and TypeScript interfaces without duplicating code? Let's answer each of these step by step.

2. Understand Host and Remote Before Going Further
Two terms appear everywhere in micro-frontend literature: Host and Remote.

  • A Host is an application that dynamically loads code provided by another application at runtime.
  • A Remote is an application that exposes selected modules, routes, or components so other applications can load them.


In our School Management System, shell-app acts as the Host, while student-app and fee-app act as Remotes.

Key Architectural Nuance: Host and Remote are runtime roles, not permanent identities. If fee-app is configured to embed a compact StudentSummaryCardComponent exposed by student-app, then for that specific interaction, fee-app becomes the Host and student-app is the Remote. An application can act as both a Host and a Remote simultaneously.
Also, turning an Angular app into a micro-frontend does not automatically make all of its internal code public. A Remote stays completely encapsulated by default—it must explicitly expose specific entry points, and a Host must explicitly configure where to fetch them.

3. The First Type of Sharing: Loading Code at Runtime
Suppose the Academic team has built a StudentListComponent inside student-app. The Platform team wants shell-app to render this feature whenever a user visits /students.

  • Bad Option (Copy-Paste): Copy the component into shell-app. Now two teams maintain duplicate code that quickly drifts out of sync.
  • Build-Time Option (NPM/Workspace Library): Package StudentListComponent as a library and compile it into shell-app. While valid for small UI widgets, this defeats independent deployment: every time the Academic team updates student search, shell-app must be rebuilt and redeployed.
  • Runtime Option (Federation): Use Webpack Module Federation or Native Federation so shell-app fetches student-app's compiled JavaScript bundles over the network at runtime.

The runtime flow works in five steps:

  1. student-app declares which routes or components it wants to expose in its federation configuration.
  2. student-app compiles and deploys its static assets to its own hosting URL (e.g., https://students.school-portal.com).
  3. shell-app reads a runtime manifest that maps the logical name "student" to that deployment URL.
  4. When a user navigates to /students, shell-app downloads the remote entry file from student-app.
  5. Angular bootstraps the lazy-loaded routes inside shell-app's router outlet.

What Does Exposing a Module Look Like?
Think of exposing as publishing a strict public menu of entry points that external applications are allowed to import. Inside student-app, the folder structure looks like this:


First, student-app defines its internal feature routes using standard Angular lazy loading:


Next, student-app exposes ./Routes in its federation.config.js (shown here using @angular-architects/native-federation):

How Does Shell Load Those Routes (With Fault Tolerance)?
To avoid hardcoding remote URLs inside TypeScript files, shell-app uses a federation.manifest.json file fetched at startup. This allows DevOps teams to promote builds from staging to production just by updating JSON:


Then, inside shell-app's router configuration, we use loadRemoteModule to dynamically pull in the remote routes—wrapping the call in a .catch() block so that if fee-app is temporarily offline, the user sees a friendly fallback screen instead of a broken router:


Notice what shell-app does not contain: it has zero knowledge of StudentListComponent, fee tables, or their internal services. shell-app only knows the contract: "When the URL starts with /students, load ./Routes from the student remote."

4. Webpack Module Federation vs. Angular Module Federation vs. Native Federation
These three terms appear constantly in the Angular ecosystem, and it helps to separate the underlying mechanism from the wrapper tooling:

And What About Web Components (@angular/elements)?
Web Components solve framework encapsulation, not module transport. Using @angular/elements, you can wrap an Angular component into a browser-standard Custom Element:

- When to use Web Components: You are building a multi-framework platform (for example, shell-app is in Angular, student-app is in React, and fee-app is in Vue), or you need to run two incompatible major versions of Angular side by side on the same page.
- How they relate to Federation: Web Components do not download themselves. You still use Native Federation or Module Federation to fetch the JavaScript bundle at runtime, and then render the exposed Custom Element tag in the DOM.

5. How Do Student and Fee Communicate?
Loading remote code is only half the battle. Now suppose a school administrator searches for a student inside student-app and clicks "View Fees":

  • Student ID: 101
  • Student Name: Rahim fee-app needs to open and display Rahim's invoices. How should that information travel from student-app to fee-app? While you could have student-app directly import code from fee-app, doing so creates tight coupling between two sibling remotes. Instead, a clean, decoupled flow delegates orchestration to the Host and data authority to the backend:

  • student-app only knows a user wants to view fees for studentId: '101'. It does not even need to know what URL structure fee-app uses (/fees/101 vs /billing?student=101).
  • shell-app owns global navigation rules.
  • fee-app is completely self-sufficient—whether a user arrives via student-app, clicks a bookmarked link, or refreshes the browser on /fees/101, fee-app works identically.

Step 1: Student Dispatches a Scoped Custom Event
When the user clicks View Fees, student-app emits a standard browser CustomEvent:


Step 2: Shell Listens Cleanly and Navigates
Inside shell-app, we listen for the school:open-student-fees event. Using RxJS fromEvent alongside Angular's takeUntilDestroyed() guarantees the event listener is automatically cleaned up if the component is destroyed—preventing memory leaks:


(Note: If your entire platform is strictly Angular and you allow remotes to know top-level route paths, student-app can also inject Angular's Router directly and call this.router.navigate(['/fees', studentId]). Using a Custom Event or a shared navigation service simply provides stronger decoupling so student-app never hardcodes fee-app's routes.)

Step 3: Fee Reactively Reads the URL and Fetches Data
Once shell-app navigates to /fees/101, fee-app reads the studentId parameter.

Avoid using this.route.snapshot.paramMap.get('studentId') in production if the component can stay mounted while the parameter changes (for example, selecting another student from a quick-switcher). Instead, in modern Angular, enable withComponentInputBinding() in your router config and bind the route parameter directly to a reactive Signal Input:


Security Rule: Passing 101 in the URL is a convenience identifier, not proof of authorization. Your Django API must still verify the user's JWT/session token to confirm they have permission to view student 101's financial records.

6. Four Common Communication Mechanisms (And How to Choose)
Enterprise micro-frontends rarely rely on just one communication pattern. Instead, combine these four techniques based on the lifecycle of the data:
Mechanism,Persistence,Coupling Level,Cross-Framework?,Best Use Case


A Closer Look at Option 3: The Shared Angular Service Trap

Developers often try to share live state by creating an AcademicContextService with an Angular Signal or RxJS BehaviorSubject in a library, importing it in both student-app and fee-app, and wondering why updates in student-app never appear in fee-app.

For a Shared Angular Service to actually share the same in-memory instance across micro-frontends, two strict conditions must be met:

- Runtime Module Singleton: The library containing the service must be listed in the shared section of federation.config.js with singleton: true in every application. Otherwise, student-app and fee-app each bundle their own separate copy of the class in JavaScript memory.
- Shared Root Injector: The service must use @Injectable({ providedIn: 'root' }) and the remotes must be loaded inside the Host's Angular injector tree (which happens automatically when using loadRemoteModule in Angular's router).
Here is what a lightweight shared context service looks like using Angular Signals:


When @school/shared-context is configured as a singleton: true shared dependency in Federation, changing the academic year dropdown in shell-app's header immediately notifies both student-app and fee-app reactively.

7. Shared Libraries vs. Runtime Federation: What's the Difference?
Suppose shell-app, student-app, and fee-app all use the same standardized ButtonComponent, ModalComponent, and EventContracts. You organize them in a monorepo workspace (such as Nx or an Angular multi-project workspace):

It is crucial to distinguish source-code sharing (build time) from bundle sharing (runtime):

- Without Federation Sharing (Build-Time Only): Each app imports @school/shared-ui. You only maintain one source folder in Git, which is great! However, when built, shell-app, student-app, and fee-app each compile ButtonComponent into their own JavaScript bundles. The user's browser downloads the button code three times.
- With Federation Sharing (Runtime Deduplication): By registering @school/shared-ui in the shared block of your federation configuration, the browser downloads @school/shared-ui once (when shell-app boots) and reuses that already-loaded JavaScript module when student-app and fee-app load.
Pro Tip on Shared Libraries: Keep your shared libraries granular. Separate pure TypeScript interfaces (@school/shared-contracts—which compile away to zero bytes of JS), stateless UI widgets (@school/shared-ui), and stateful singleton services (@school/shared-data-access). Never dump domain-specific business logic into a shared library just to avoid proper cross-app communication.

8. Putting It All Together: An Enterprise Architecture
Combining these pieces gives us a clean, scalable blueprint for our School Management System:

  1. Shell (shell-app) handles authentication, global layout, global context (like active academic year), and top-level routing.
  2. Remotes (student-app, fee-app) expose coarse-grained feature route files (./Routes) rather than dozens of tiny internal components.
  3. Navigation & Context flow through URL route parameters (/fees/101) and typed Custom Events (school:open-student-fees).
  4. Shared Libraries provide design-system consistency and TypeScript event contracts without coupling business domains.
  5. The Backend (Django API) remains the single source of truth for persistent data and security enforcement.

9. Seven Golden Rules for Production Micro-Frontends
If you are implementing this architecture in production, follow these guardrails:

  1. Keep the Shell Thin: Let shell-app orchestrate layout, authentication, and top-level routing. Never put student admission rules or fee calculation logic inside the Shell.
  2. Expose Coarse-Grained Entry Points: Expose whole feature route trees (./Routes) rather than individual buttons or form fields. The fewer entry points a Remote exposes, the easier it is to refactor its internals without breaking the Host.
  3. Pass Identifiers, Not Giant Objects: When navigating between micro-frontends, pass studentId: '101' via the URL rather than serializing a 50-field Student JSON object into a Custom Event. Let fee-app fetch the exact fields it needs from the API.
  4. Type Your Event Contracts: Put your Custom Event names and payload interfaces inside a lightweight @school/shared-contracts library so TypeScript catches typos at compile time in both the sender and receiver.
  5. Guard Against Version Skew: Use singleton: true and strictVersion: true thoughtfully for core libraries (@angular/core, @angular/router, and stateful shared services) so subtle version mismatches fail loudly in CI rather than silently breaking dependency injection in production
  6. Always Wrap Remote Loads in Fallbacks: Network glitches and failed deployments happen. Always attach a .catch() handler to loadRemoteModule() so a broken fee-app deployment displays a localized "Fee Module Temporarily Unavailable" card without crashing student-app or shell-app.
  7. Treat Remotes as Trusted Production Code: Because a federated remote executes inside the same browser window and DOM origin context as your Host, it has full access to memory and storage. Lock down your federation.manifest.json deployment pipeline with the same security rigor as your main application code.

10. The Takeaway
Micro-frontends become much easier to reason about the moment you stop treating every kind of "sharing" as the same problem:

  • Need to load a separately deployed feature at runtime? Use Native Federation or Webpack Module Federation.
  • Need to mix different frontend frameworks? Wrap the boundary in Web Components (@angular/elements).
  • Need to coordinate navigation between domains? Use URL route parameters and Browser Custom Events.
  • Need reactive, in-memory global context across Angular remotes? Use a Shared Singleton Service backed by Angular Signals or RxJS.
  • Need consistent UI components across teams? Use a Shared Workspace/NPM Library.
  • Need persistent, authorized business data? Let each micro-frontend talk to the Backend API. Start with a thin Shell, expose route-level entry points from your Remotes, keep your communication contracts minimal, and you will gain the team autonomy of micro-frontends without creating a distributed monolith.

Further Reading

_- Webpack Module Federation — Official Documentation

  • Micro Frontends with Modern Angular & Native Federation — Angular Architects (Manfred Steyer)
  • Angular Elements — Official Angular Documentation
  • Angular Router — Component Input Binding (withComponentInputBinding)_

Top comments (0)