Micro Frontends: From Distributed Frontends to Cross-Framework Integration
1. Why Do We Need Micro Frontends?
As a frontend grows, the main problem is often coupling, not code size.
Consider an application with multiple business domains:
Frontend Application
│
┌──────────────────┼──────────────────┐
│ │ │
Products Payments Admin
In a traditional setup, these areas may share the same codebase, build, and deployment.
That becomes difficult when different teams need different release cycles or ownership boundaries.
One codebase
+
One build
+
One deployment
Microfrontends allow us to split the frontend around meaningful boundaries:
Host / Shell
│
┌───────────┼───────────┐
│ │ │
Products Payments Admin
Remote Remote Remote
The value is not simply having multiple frontend projects. The value is allowing different parts of the system to evolve more independently.
Typical reasons include:
independent team ownership
independent release cycles
gradual migration from an existing frontend
separating large business domains
However, a large frontend does not automatically require microfrontends.
If one team can manage the application effectively as a single unit, introducing multiple applications may add unnecessary complexity.
The useful question is:
Do we have a boundary worth maintaining independently?
2. Building Microfrontends with Vite and Module Federation
Once we have that boundary, we need a way for independently built applications to consume each other's modules.
For this example:
Vite
+
@module-federation/vite
Reference implementation:
scaflo/monorepo-microfrontend-example
The example follows the host/remote model.
The remote exposes a module:
exposes: {
"./App": "./src/App",
}
The host then consumes it through Module Federation.
Host
│
│ requests
▼
remoteEntry.js
│
▼
Remote module
We do not need the entire repository configuration here. The important pieces are the exposed module, the remote entry, and the host configuration.
Exposing a module
The remote can define:
federation({
name: "remote",
filename: "remoteEntry.js",
exposes: {
"./App": "./src/App",
},
});
The host can then reference:
import RemoteApp from "remote/App";
Although the syntax looks like a normal import, the module is resolved from the federated remote rather than from the host's local source tree.
Sharing dependencies
If both applications use React, the federation configuration can also declare shared dependencies:
shared: [
"react",
"react-dom",
]
This lets the federation runtime handle these dependencies across the host and remote instead of treating every copy independently.
What changes?
With a traditional frontend:
Application
│
▼
Build
│
▼
All modules
With Module Federation:
Host
│
├── Local modules
│
└── Remote modules
│
▼
Other application
Part of the dependency graph can now come from another independently built application.
That is the key capability we need for this microfrontend setup.
But this still leaves another problem:
What happens when the functionality we need belongs to a different frontend ecosystem?
3. Cross-Framework Integration: Using a React Package Inside Vue
Suppose the application is built with Vue, but the package we want is built for React.
Vue Application
│
▼
React Package
For this example, we can use Agentation, a React-based package for visual UI annotation and feedback.
Instead of rewriting the package, we can mount the React component inside the Vue application.
The boundary is simply a DOM element:
Vue
│
▼
DOM element
│
▼
React root
│
▼
Agentation
The Vue component:
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import React from "react";
import {
createRoot,
type Root,
} from "react-dom/client";
import { Agentation } from "agentation";
const container = ref<HTMLElement | null>(null);
let root: Root | null = null;
onMounted(() => {
if (container.value) {
root = createRoot(container.value);
root.render(
React.createElement(Agentation)
);
}
});
onUnmounted(() => {
if (root) {
root.unmount();
root = null;
}
});
</script>
<template>
<div
id="agentation-bridge-root"
ref="container"
/>
</template>
The bridge starts with the Vue-managed container:
<div
id="agentation-bridge-root"
ref="container"
/>
Once Vue mounts it, React receives that DOM node:
root = createRoot(container.value);
and renders Agentation into it:
root.render(
React.createElement(Agentation)
);
The result is:
Vue Application
│
└── #agentation-bridge-root
│
└── React
│
└── Agentation
We are not converting React into Vue.
Vue owns the surrounding application, while React manages its own subtree inside the container.
Lifecycle
Because Vue owns the component lifecycle, the React root must be cleaned up when the Vue component is removed:
onUnmounted(() => {
if (root) {
root.unmount();
root = null;
}
});
So:
Vue mounts
↓
createRoot()
↓
Render Agentation
↓
Vue unmounts
↓
root.unmount()
When does this make sense?
This approach is most useful for relatively isolated packages or utilities where a small integration boundary is enough.
For example:
Vue
│
└── Integration boundary
│
└── React utility
The trade-off is that the application now includes another runtime and framework ecosystem, so bundle size, dependency versions, CSS isolation, and lifecycle management still need to be considered.
The technique is useful because it gives us another option besides rewriting or replacing a package simply because it belongs to a different framework.