Skip to main content
Blog

Intro

Large frontend applications become unwieldy as the code base and the team grow. At some point, it makes sense to split up the monolith. This guide explores microfrontends — bringing the microservices approach from backend to frontend — with a focus on Module Federation for runtime module sharing and practical advice for working across different frameworks.

The Microfrontend Approach

Let's get some definitions out of the way: A microfrontend is a small, self-contained part of a web application's user interface that can be developed and deployed independently. Instead of one massive application, you break your UI into pieces by domains (business areas) owned by different teams. Each team becomes cross-functional, developing features end-to-end.

The core ideas are:

  • autonomous teams that can choose their own stack
  • decoupled codebases with no shared state
  • incremental upgrades
  • independent deployment pipelines

Integration Strategies

A common architecture is to have a microfrontend for each page and a single container app that renders common page elements and takes care of cross-cutting concerns, like navigation.

You can implement this setup e.g. on the server side, rendering the html and plugging in page specific content from fragment html files via server side includes.

Or you can integrate your micro frontends at runtime !

Runtime integration is where things get interesting because teams can truly create a seamless user experience (no hard refreshes) and teams can work independently. To achieve runtime integration techniques like iframes, web components, or module federation can be utilized.

Cross-Application Communication

When it comes to communication: Keep it minimal — the more your microfrontends talk to each other, the more coupled they become. When you do need communication, use clear contracts and patterns like custom events, router params, or passing data down through the shell app.

For backend communication, the Backend for Frontend (BFF) pattern works well. Each microfrontend has its own backend service, so teams own their features completely without cross-team API dependencies.

Module Federation

Module Federation solves the runtime integration challenge elegantly. It allows multiple applications to share code and resources while staying isolated. Each build acts as a container that can expose and consume modules, and these modules are loaded asynchronously at runtime.

First introduced in Webpack 5, its now also available in vite with the module federation plugin. You can combine different builds into a single application where each build exposes modules like components or utilities, and shared dependencies prevent version conflicts and duplication.

Demo

Here's a practical simple example with a React host app consuming a Vue remote. The setup uses yarn workspaces and showcases framework independence.

The following code snippets are from vite-microfrontend-poc.

Vue Remote Configuration

The Vue remote exposes a button component and mount utility by defining a remoteEntry.js file in its vite config:

// Vue Remote vite.config.js
federation({
  name: "remoteVueApp",  // UID for this remote
  filename: "remoteEntry.js",  // generated manifest listing exposed modules
  exposes: {
    "./Button": "./src/main.ce.js",  // button as web component
    "./mountApp": "./src/utils/mountApp.js",  // full app mount utility
  }
})

The exposes property maps publicly exposed paths to local files.

At runtime, when the host imports e.g. remoteVueApp/Button, that corresponding chunk is dynamically loaded.

In our case, main.ce.js, which exports the Vue button as a Web Component for framework-agnostic use, and mountApp.js, which provides a function to mount the entire Vue app to a DOM element, are exposed.

Host App Configuration

Now, the React host app can reference the Vue remote in its vite.config.js:

// Host vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
 
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "app",
      remotes: {
        remoteVueApp: "http://localhost:5002/assets/remoteEntry.js",
      },
      shared: ["react", "react-dom"],
    }),
  ],
  build: {
    modulePreload: false,
    target: "esnext",
    minify: false,
    cssCodeSplit: false,
  },
});

The remotes property maps remote identifiers to their remoteEntry.js URLs, which the host fetches at runtime to discover and load available modules.

Note we need to serve the remote app using a strict port assignment: vite preview --port 5002 --strictPort.

Solving Cross-Framework Challenges

Working with microfrontends built with different frameworks poses some interesting challenges. Let's explore practical patterns for communication and component integration between our React host and Vue remote.

Custom Events for Communication

Using native custom browser events keeps coupling loose while allowing necessary communication. In the demo repo, a simple event service handles communication between microfrontends, packaged as a shared library in the monorepo's packages workspace and installed as dependency in both apps.

{
  "name": "@packages/services",
  "version": "1.0.0",
  "main": "CustomEvents.js",
  "private": false,
  "dependencies": {}
}
export const CustomEvents = {
  count: 'count',
};
 
export const CustomEventService = {
  emit: (event, body) => {
    const customEvent = new CustomEvent(event, body);
    window.dispatchEvent(customEvent);
  },
  subscribe: (event, listener) => {
    window.addEventListener(event, listener);
  },
  unsubscribe: (event, listener) => {
    window.removeEventListener(event, listener);
  },
};

Now, the remote app can emit specific custom events, which the host app subscribes to, and communication is completely framework agnostic.

Vue as Web Components

To use Vue components in a React host (or anywhere else), expose them as web components. Vue makes this straightforward:

// main.ce.js
import { defineCustomElement } from "vue";
import Button from "./components/Button.ce.vue";
 
const ButtonWC = defineCustomElement(Button);
customElements.define("button-web-component", ButtonWC);
export default ButtonWC;

The React host can then use <button-web-component> as a standard custom element.

Loading Remote Modules in React

Now that the React Host can dynamically import modules from the Vue app, let's look into how to integrate them.

Each route in the code snippet below demonstrates an integration pattern, and we’ll focus on the last two, since these deal with integrating Vue modules in React:

  • • Loading a Vue component as a Web Component (in VueButtonContainer.jsx)
  • • Mounting the full Vue application inside React (in VueAppContainer.jsx)
// main.jsx
import React, { lazy, Suspense } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import VueAppContainer from "./components/VueAppContainer";
import VueButtonContainer from "./components/VueButtonContainer";
 
const RemoteApp = lazy(() => import("remoteApp/App"));
 
const router = createBrowserRouter([
  {
    path: "/",
    Component: App,
    children: [
      {
        path: "remote-react-app",
        Component: () => (
          <Suspense fallback={<div>loading remote react app...</div>}>
            <RemoteApp />
          </Suspense>
        ),
      },
      {
        path: "remote-vue-component",
        Component: VueButtonContainer,
      },
      {
        path: "remote-vue-app", 
        Component: VueAppContainer,
      },
    ],
  },
]);
 
ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

Within VueButtonContainer, the web component from the remote Vue app is dynamically imported and rendered. When that chunk loads, it executes the code generated from the remote’s main.ce.js, which registers the custom element.

// VueButtonContainer.jsx
import React, { useEffect } from "react";
 
const VueButtonContainer = () => {
  useEffect(() => {
    const loadVueRemoteButton = async () => {
      try {
        await import("remoteVueApp/Button");
      } catch {
        console.error("failed to load remote vue button");
      }
    };
    loadVueRemoteButton();
  }, []);
 
  return (
      <button-web-component />
  );
};
 
export default VueButtonContainer;

The full Vue application is mounted inside VueAppContainer. The remote mountApp function is dynamically imported and called with a DOM element to render the full app.

// VueAppContainer.jsx
import React, { useRef, useEffect } from "react";
 
const VueAppContainer = () => {
  const ref = useRef(null);
 
  useEffect(() => {
    const loadVueRemoteApp = async () => {
      try {
        const { default: mountApp } = await import("remoteVueApp/mountApp");
        if (ref.current) {
          mountApp(ref.current);
        }
      } catch {
        console.error("failed to load remote vue app");
      }
    };
    loadVueRemoteApp();
  }, []);
 
  return <div ref={ref}></div>;
};
 
export default VueAppContainer;
// remoteVueApp/mountApp
import { createApp } from "vue";
import App from "../App.vue";
import { router } from "../main";
 
export default function mountApp(el) {
  createApp(App).use(router).mount(el);
}

Running the Setup

If you want to checkout the basic demo vite-microfrontend-poc you can start all apps (a React host, a React remote and a Vue remote), from the root with:

yarn run serve

Concurrently and wsrun are used to serve all applications in parallel (running their individual serve scripts: yarn run build && yarn run preview). The --exclude @packages/services flag is required as the services package is just a shared utility library — it doesn't have a serve script like the host and remote apps.

"serve": "concurrently \"wsrun --parallel --exclude @packages/services serve\""

This builds and serves all apps:

[0] remote
[0]  |   ➜  Local:   http://localhost:5001/
[0]  |   ➜  Network: use --host to expose
[0] vue-remote
[0]  |   ➜  Local:   http://localhost:5002/
[0]  |   ➜  Network: use --host to expose
[0] host
[0]  |   ➜  Local:   http://localhost:4173/
[0]  |   ➜  Network: use --host to expose

Making the remote entry file for the Vue App accessible at http://localhost:5002/assets/remoteEntry.js. You can open the host app at http://localhost:4173/ in your browser. Navigating to /remote-vue-component or /remote-vue-app will demonstrate the Vue integration, we went throw above.

Conclusion

Although we just walked through a simple demo, we showed how vite's module federation plugin simplifies runtime integration between microfrontends.

The microfrontend approach itself can give you the freedom to mix technologies and develop features independently, but it can also add complexity.

If you’re working on a large, complex project, start small: pick a single feature that could live in its own microfrontend and experiment with deploying it independently. That way, you get a feel for the trade-offs before committing to a full microfrontend architecture.