DEV Community

Cover image for Setup a Micro-Frontend architecture in 15min with Vite!
Meidi Airouche
Meidi Airouche

Posted on • Edited on

Setup a Micro-Frontend architecture in 15min with Vite!

In a monolithic frontend architecture, a single codebase handles the entire user interface. While this can simplify initial development, it can become complex as the application grows:

  • Scaling: Large teams working in a monolithic repo may face merge conflicts, slower CI/CD pipelines, and difficulty with dependencies
  • Independency: sometimes working on shared stuff can impact other teams
  • Resilience: a failure can bring down the whole application

Micro-frontends can help when you start facing those problems. Unlike Web Components, coming with limited inter-framework communication and lifecycle management challenges, Vite based micro-frontends allow developers to work with differents frameworks. They provide flexibility in tooling, better state management, and more robust integration options. In a real life software growing through years, being able to handle multiple frameworks can be a smooth way to migrate from an old one to a new one whenever it's needed.

In this article, we’ll create a micro-frontend setup using Vite as our build tool and combine Vue.js, Angular, and React components into a unified experience. The example? A modular news portal, where each framework handles a specific section.

Modular News Portal

This modular news portal will have :

  • a Header made with Vue.js: with a simple navigation bar
  • a Trending made with React: with the last articles to show
  • an Highlights made with Angular: with the most popular articles to show

In a real-world example, separating news management across multiple technologies wouldn’t be ideal, but it serves our example well.

Building the Shell

In micro-frontend architecture, the Shell acts as the container for the micro-frontends. It has 3 main features :

  • Coordination: The Shell is responsible for loading and rendering the different micro-frontends into designated areas of the application
  • Orchestration: It handles global concerns like navigation, shared styles, shared state, data...
  • Entry Point: This is what the users loads first in their browser. It can also provide fallbacks if one of the micro-frontends fails to load.

The Shell in our setup will use Vite and load ESM modules dynamically:

host/
├── index.html  # Main entry point
├── main.js     # Loads the micro-frontends
├── vite.config.js
├── package.lock.json
├── package.json
apps/            # Contains individual micro-frontends
├── header/
    ├── src/
        ├── components/
            ├── Header.vue
        ├── main.js
    ├── vite.config.js
    ├── package.lock.json
    ├── package.json
├── trending/
    ├── src/
        ├── components
            ├── Trending.jsx
        ├── main.jsx
    ├── eslint.config.js
    ├── package.lock.json
    ├── vite.config.js
├── highlights/
Enter fullscreen mode Exit fullscreen mode

Let's build it !

We start by initializing a Vite workspace for our host and micro-frontends.

mkdir news-portal && cd news-portal  
npm init vite@latest host --template vanilla  
Enter fullscreen mode Exit fullscreen mode

Let’s organize the project to separate each micro-frontend:

mkdir -p apps/header apps/trending apps/highlights
Enter fullscreen mode Exit fullscreen mode

Now, let's create our simple index.html with the components architecture inside our DOM :

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>News Portal</title>
</head>
<body>
  <div id="header"></div>
  <div id="trending"></div>
  <div id="highlights"></div>
  <script type="module" src="./src/main.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now, let's create our main.js file that is responsible of mounting the micro-frontends (note that imports won't work until we build our micro-frontends):

// main.js
import { mount as mountHeader } from '../apps/header/dist/header.js';
import { mount as mountTrending } from '../apps/trending/dist/trending.js';
import { mount as mountHighlights } from '../apps/highlights/dist/highlights.js';

mountHeader(document.querySelector('#header'));
mountTrending(document.querySelector('#trending'));
mountHighlights(document.querySelector('#highlights'));
Enter fullscreen mode Exit fullscreen mode

Then, we create the Vite configuration inside vite.config.js to enable the Vite server :

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    port: 3000,
    open: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

Start the Shell to be ready to serve :

cd host
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

And here we are : we've successfully created our Shell and it's ready to serve our future micro-frontends. Now, let's create them !

Building the Header with Vue 3

Let's create our Header folder inside apps and navigate into it:

cd apps
npm init vite@latest header --template vue  
cd header  
npm install  
Enter fullscreen mode Exit fullscreen mode

Inside src/components/Header.vue, create a simple header with navigation and for example a search bar:

<template>
  <header class="header">
    <nav>
      <ul>
        <li><a href="#">Home</a></li>
        <li><a href="#">World</a></li>
        <li><a href="#">Tech</a></li>
        <li><a href="#">Sports</a></li>
      </ul>
    </nav>
    <input type="text" placeholder="Search news..." />
  </header>
</template>

<script>
export default {
  name: 'Header',
};
</script>

<style scoped>
.header {
  display: flex;
  justify-content: space-between;
  padding: 1em;
  background: #333;
  color: white;
}
nav ul {
  display: flex;
  list-style: none;
}
nav ul li {
  margin-right: 1em;
}
input {
  padding: 0.5em;
}
</style>
Enter fullscreen mode Exit fullscreen mode

We need a src/main.js to mount the component :

import { createApp } from 'vue';
import Header from './components/Header.vue';

export function mount(el) {
  createApp(Header).mount(el);
}
Enter fullscreen mode Exit fullscreen mode

Configure vite.config.js to expose this app as a library :

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: './src/main.js',
      name: 'Header',
      fileName: 'header',
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Finally, build the micro-frontend to generate the dist folder :

cd apps/header
npm run build 
Enter fullscreen mode Exit fullscreen mode

You should now be able to see your Header served in your Shell. It happens because we told to our Shell to serve the dist folder of our Header and we generated it with the npm build command.

Building the Trending section with React 18

Let's create our Trending folder inside apps and navigate into it:

cd apps
npm init vite@latest trending --template react  
cd trending
npm install  
Enter fullscreen mode Exit fullscreen mode

Add a Trending component in src/components/Trending.jsx

import React from 'react';

const Trending = () => {
  const articles = [
    { id: 1, title: "React 18 Released", summary: "Learn what's new in React 18." },
    { id: 2, title: "AI Revolution", summary: "How AI is transforming industries." },
  ];

  return (
    <section className="trending">
      <h2>Trending News</h2>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>
            <h3>{article.title}</h3>
            <p>{article.summary}</p>
          </li>
        ))}
      </ul>
    </section>
  );
};

export default Trending;
Enter fullscreen mode Exit fullscreen mode

We need a src/main.jsx to mount the component :

import React from 'react';
import ReactDOM from 'react-dom/client'; 
import Trending from './components/Trending';

export function mount(el) {
  const root = ReactDOM.createRoot(el); 
  root.render(<Trending />); 
}
Enter fullscreen mode Exit fullscreen mode

Configure vite.config.js :

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: './src/main.jsx',
      name: 'Trending',
      fileName: 'trending',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Build to generate the dist folder :

npm run build
Enter fullscreen mode Exit fullscreen mode

And here we are ! We now have our second micro-frontend served inside our Shell, under our Header.

Building Highlights with Angular 19

Edit: I haven't been able to make Vite works with Angular 19 without module federation, custom element or widget yet. I'm currently trying to find the best approach between the 3 to propose you the most efficient in a later edit of this post.


Bonus: Runtime Federation with Vite

While the approach described above focuses on combining microfrontends during the build step, for production, you might want to embrace runtime federation. This approach offers true independence for microfrontends, allowing each module to be deployed separately and updated independently.

Each microfrontend can be hosted on its own server or domain. For example:

Dynamic Loading in the Shell Application

The shell application dynamically loads microfrontends at runtime using import() or similar methods. Here’s how the shell could dynamically load both the Header and Trending modules from their respective URLs, listed in a microfrontend manifest json document, in the Shell main.js :

Manifest of microservices (ex: https://news-portal.com/microfrontends.json) :

[
  {
    "name": "Header",
    "url": "https://header.news-portal.com/dist/header.js"
  },
  {
    "name": "Trending",
    "url": "https://trending.news-portal.com/dist/trending.js"
  }
]
Enter fullscreen mode Exit fullscreen mode

Shell main.js :

export default {
  data() {
    return {
      modules: [],
    };
  },
  async mounted() {
    try {
      const response = await fetch('https://news-portal.com/microfrontends.json');
      const microfrontends = await response.json();

      for (const mfe of microfrontends) {
        const module = await import(mfe.url);
        this.modules.push({
          name: mfe.name,
          component: module.default,
        });
      }
    } catch (error) {
      console.error('Failed to load microfrontends:', error);
    }
  },
  template: `
    <div>
      <h1>Shell App</h1>
      <div v-for="module in modules" :key="module.name">
        <component :is="module.component" />
      </div>
    </div>
  `,
};
Enter fullscreen mode Exit fullscreen mode

Sharing Dependencies

To avoid duplicating common dependencies like Vue, we could use peerDependencies in our package.json and externalize them in the Vite build process. We can configure it in vite.config.js :

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue'], // Prevent bundling Vue with the module
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

This ensures that the Shell provides the dependency (e.g., Vue), and both Header and Trending modules rely on the same instance.

Benefits of Runtime Federation

  • Independent Deployments: Each module can be updated without redeploying the entire application.
  • Scalability: Teams can work on different microfrontends autonomously.
  • Dynamic Composition: New modules can be added or updated without touching the shell or other microfrontends.

Common pitfalls

Port Conflicts

  • Assign a unique port to each micro-frontend in vite.config.js or angular.json to avoid conflicts

Shared Libraries

  • Use a shared CDN for common libraries to reduce duplication and bundle sizes
  • Ensure version compatibility to avoid runtime errors

CORS problems

During local development, micro-frontends hosted on different ports may face CORS issues due to browser security policies

For production, configure your web server to allow cross-origin requests:

add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept';
Enter fullscreen mode Exit fullscreen mode

Minimize CORS requirements by deploying all micro-frontends and the Shell under the same domain or subdomain structure (e.g., https://news-portal.com/header, https://news-portal.com/trending).

Github link

Github to the code : Code Repository

Conclusion

Congratulations, you've setup your first MicroFrontend architecture with Vite. For those who already used web components in the past, you may find it really simpler and useful. I hope it will be a good help to set up your first MFE projects whenever you'll need to decouple the development of multiple front parts of your software.

Top comments (36)

Collapse
 
ozzythegiant profile image
Oziel Perez

This is great for SPAs, but if you guys just want a traditional website with different frameworks for functionality, try Astro instead

Collapse
 
mairouche profile image
Meidi Airouche

When choosing between Vite and Astro for microfrontends, it depends on your project’s needs.

Imagine you're building a SaaS dashboard for analytics with microfrontends:

  • Real-time Data Visualization (React): Graphs, charts, and stats that update live via WebSocket or polling.
  • User Management (Vue): Complex CRUD operations with modals, forms, and validation.

Where Astro is Limited:

  • Real-time Interactions: Astro’s static-first model means it struggles with highly dynamic components like live-updating graphs or dashboards that need frequent re-rendering based on API data. You’d have to include several "hydrated islands" (Island Architecture oriented), which increases complexity and runtime costs as Astro initializes these components separately.
  • Complex State Management: Dynamic state handling (e.g., syncing data between user actions and server updates) is cumbersome in Astro because it isn’t built for full-fledged SPAs or highly interactive systems.

Why Vite Excels:

  • Dynamic Features: Vite handles WebSocket-based updates and dynamic rendering seamlessly, with frameworks like React or Vue fully supported out of the box. For example, a live-updating dashboard built with Vite remains performant and developer-friendly.
  • Development Speed: Vite’s Hot Module Replacement (HMR) ensures that developers can see live updates to components like charts or forms without a full reload, even when working across multiple microfrontends.
  • Interoperability: In the SaaS dashboard, you can mix frameworks (React for visualizations, Vue for forms) easily with Vite’s ecosystem and tools like Module Federation to manage independent builds.

Conclusion

Astro is ideal for static pages, but for the dynamic, interactive SaaS dashboard core, Vite’s performance, flexibility, and robust runtime support can make it a better choice.

Collapse
 
juststevemcd profile image
Steve McDougall

100% recommend Astro over anything else these days. Unless you need something specific from one of the other *kit frameworks

Collapse
 
mairouche profile image
Meidi Airouche
Collapse
 
florianrappl profile image
Florian Rappl

Sorry, but I don't see the "micro frontend architecture". There are some core properties that are unanswered (or maybe I could not see / find it):

  • The composition should be possible at runtime; but here everything is composed in one build step. In general this is possible (build-time composed MFs), but then
  • The sources must be able to come from different repositories (here its all in one repository)

Generally, if you look at things such as Module Federation, Native Federation, Piral, ... these solutions give you something that is distributed from the beginning, i.e., where you (by default) have runtime composition.

Having team autonomy (which is the most important aspect of a micro frontends architecture) is only possible with independent deployment processes. So how would I update, e.g., the trending module, without updating the whole application? How could I add another module without having to redeploy the application?

Sorry if my comment comes across negative - it is meant as an encouragement; not as a letdown.

Collapse
 
mairouche profile image
Meidi Airouche

Thank you for your comment! Every feedback is welcome.

You’re absolutely right that runtime composition and independent deployments are very important. My article primarily focuses on the get started, where modules are combined via dist directories for simplicity of the article and quick setup.

However, for production, Vite can indeed support runtime federation by leveraging:

  • Dynamic imports to load modules hosted on separate DNS addresses.
  • Shared dependencies using peerDependencies to avoid duplication.
  • Independent deployments, allowing teams to update or add modules without redeploying the entire application.

I’ll update the article to clarify that the dist approach is meant to onboard, while runtime federation is better suited for production. Thanks again for your valuable feedback, it helps make the article more complete! 😊

Collapse
 
mairouche profile image
Meidi Airouche

I added the bonus section to the article to explain how :) Thank you

Collapse
 
han_yang_20969b18accbe79e profile image
Han Yang

very good tutorial, but angular v19 do not have .\app\app.module.ts, in dist folder, vue generated header.umd.cjs file, react do not generate trending.umd.js.

Collapse
 
mairouche profile image
Meidi Airouche

I passed everything in ESM since it's more standard now. Thank you for the feedback <3

Collapse
 
han_yang_20969b18accbe79e profile image
Han Yang • Edited

current status - 2024-12-11 - base concept works fine

Image description

Thread Thread
 
mairouche profile image
Meidi Airouche

I added the repository with the overall code working (except for Angular) at the end of the article : github.com/mairouche/mfe-news-portal

Collapse
 
naveen_ravichandren_a7b58 profile image
Naveen Ravichandren

Thanks, this is kick-ass.
I have a doubt, how would u share data across these 2 components? Vue to react and vice versa

Collapse
 
mairouche profile image
Meidi Airouche

With Redux for state management for example which is accessible from Vue, React or even Angular.
With RxJS for cross-component events since it's also compatible with each lib/framework.

You can also have shared data in the Shell like following for example :

// shared/service.js
let sharedData = null;

export const getSharedData = () => sharedData;
export const setSharedData = (data) => { sharedData = data; };

// React Microfrontend
import { setSharedData, getSharedData } from './service';

setSharedData('Data from React');
console.log(getSharedData());

// Vue Microfrontend
import { setSharedData, getSharedData } from './service';

setSharedData('Data from Vue');
console.log(getSharedData());
Enter fullscreen mode Exit fullscreen mode
Collapse
 
juan_labrada_42e0d23118f4 profile image
Juan Labrada

Absolutely loved this post!!

Collapse
 
mairouche profile image
Meidi Airouche

Thank you very much <3

Collapse
 
griffinrivera profile image
Griffin Rivera

Thanks for sharing.

Collapse
 
farseenmanekhan1232 profile image
Mohammad Farseen Manekhan

This is amazing.
This is exactly what's happening in Astro behind the hood.
Correct me if I'm wrong.

Collapse
 
mairouche profile image
Meidi Airouche

To be Honest I discovered Astro with a previous comment and I'll deep dive into it. It looks like the same with Astro being maybe a bit more opinionated and closer to a framework than a library. But I'll look for it to see if I well understood

Collapse
 
mairouche profile image
Meidi Airouche

After deep diving into astro and trying it (only for 2 days so not that much background at this stage), this is what I conclude :
dev.to/mairouche/comment/2ka02

Collapse
 
kevinluo201 profile image
Kevin Luo

How does the router work in this case?

Collapse
 
mairouche profile image
Meidi Airouche

This is a good question. I was affraid that the article would be too long if I deep dived into routing, state, refs etc...

I'll answer here then.

In the Shell, you can use a vue or react router (what you prefer) to route to any kind of component. For example :

import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  { path: '/', component: HomePage },
  { path: '/vue-mf', component: () => import('vueMicrofrontend/App') },
  { path: '/react-mf', component: () => import('reactMicrofrontend/App') },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;
Enter fullscreen mode Exit fullscreen mode

Once the microfrontend is loaded throught the global Shell router, it uses its own internal router if it has one.

So a good practice would be to keep the Global Shell router just load the micr-frontends and then, each micro-frontends having its own dedicated standalone router.

Collapse
 
mahmoudalaskalany profile image
Mahmoud Alaskalany

Thanks for this post i will try this with angular

Collapse
 
ciphernutz profile image
Ciphernutz

Absolutely agree with your points. This is a game-changer!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.