Vite’s architecture for micro-frontends is less about a single, monolithic build process and more about orchestrating independent development and deployment lifecycles that then assemble into a cohesive user experience.

Let’s see it in action. Imagine we have two micro-frontends: a product-catalog and a user-profile.

product-catalog (App 1):

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

const app = createApp(App)

// Expose a mount function for the shell app to use
app.mount = (selector) => {
  const el = document.querySelector(selector)
  if (el) {
    app._container = el
    app.mount(el)
  } else {
    console.error(`Element with selector "${selector}" not found for product-catalog.`)
  }
}

// Expose an unmount function
app.unmount = () => {
  app.unmount()
  if (app._container) {
    app._container.innerHTML = ''
  }
}

export default app
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    // Target a library that exposes modules
    // This is key for micro-frontends
    lib: {
      entry: 'src/main.js',
      name: 'ProductCatalog', // Global variable name if used directly
      fileName: (format) => `product-catalog.${format}.js`
    },
    // Rollup options to prevent conflicts
    rollupOptions: {
      // Make sure to externalize dependencies that are provided by the shell or other apps
      external: ['vue'],
      output: {
        // Provide global variables to use in the UMD build
        // for externalized dependencies
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

user-profile (App 2):

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

const app = createApp(App)

app.mount = (selector) => {
  const el = document.querySelector(selector)
  if (el) {
    app._container = el
    app.mount(el)
  } else {
    console.error(`Element with selector "${selector}" not found for user-profile.`)
  }
}

app.unmount = () => {
  app.unmount()
  if (app._container) {
    app._container.innerHTML = ''
  }
}

export default app
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    lib: {
      entry: 'src/main.js',
      name: 'UserProfile',
      fileName: (format) => `user-profile.${format}.js`
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

shell-app (The orchestrator):

// src/App.vue
<template>
  <div>
    <h1>My Awesome App</h1>
    <button @click="loadProductCatalog">Load Product Catalog</button>
    <button @click="loadUserProfile">Load User Profile</button>
    <div id="micro-frontend-container"></div>
  </div>
</template>

<script setup>
let currentMicroFrontend = null;

const loadMicroFrontend = async (appName, mountSelector) => {
  // Unmount previous if it exists
  if (currentMicroFrontend) {
    currentMicroFrontend.unmount();
  }

  try {
    // Dynamically import the micro-frontend bundle
    const microFrontendModule = await import(`../micro-frontends/${appName}/dist/product-catalog.es.js`); // Example for product-catalog
    const app = microFrontendModule.default; // Assuming default export is the Vue app instance

    // Mount the micro-frontend
    app.mount(mountSelector);
    currentMicroFrontend = app;
  } catch (error) {
    console.error(`Failed to load ${appName}:`, error);
  }
};

const loadProductCatalog = () => {
  loadMicroFrontend('product-catalog', '#micro-frontend-container');
};

const loadUserProfile = () => {
  loadMicroFrontend('user-profile', '#micro-frontend-container');
};
</script>
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  // Serve micro-frontends from a common directory
  resolve: {
    alias: {
      '@micro-frontends': path.resolve(__dirname, './micro-frontends')
    }
  },
  // Serve the shell app and allow access to micro-frontend dist files
  server: {
    fs: {
      allow: [
        '../micro-frontends/product-catalog/dist',
        '../micro-frontends/user-profile/dist'
      ]
    }
  }
})

The core idea here is that each micro-frontend is built as a library (using build.lib in vite.config.js). This produces a JavaScript file that can be dynamically imported by the shell application. The shell application then takes responsibility for mounting and unmounting these independent applications into designated DOM elements. The rollupOptions with external and globals are critical for managing shared dependencies like Vue, preventing each micro-frontend from bundling its own copy and causing version conflicts or bloat.

The most surprising truth about this setup is that Vite doesn’t inherently enforce a micro-frontend architecture. It’s a build tool that’s incredibly good at producing highly optimized, standalone JavaScript modules. You then choose to leverage that capability to build independent applications that can be composed at runtime. The server.fs.allow in the shell app’s Vite config is a simple way to make the built micro-frontend assets available during development, but in production, these would typically be served from a CDN or separate deployment.

The mental model is one of independent deployability. Each micro-frontend is a separate project, with its own repository, build pipeline, and deployment schedule. The shell app acts as a router and orchestrator, deciding which micro-frontend to load and display based on user actions or routes. The import() statement is the magic glue, enabling dynamic loading of these independent bundles.

A critical, often overlooked detail is how you handle inter-micro-frontend communication. While not shown in this basic example, you’d typically use custom events, a shared state management library (if carefully managed), or a simple pub/sub pattern exposed by the shell. Directly calling functions across micro-frontend boundaries can lead to tight coupling, defeating the purpose of independence.

The next concept to explore is how to handle routing within and between micro-frontends, and strategies for managing shared state without creating a tangled mess.

Want structured learning?

Take the full Vite course →