All posts

5 min readWeb Development

Microfrontends in Simple Terms: Vue + React, Deployed on Vercel

Build two tiny apps, one in Vue and one in React, join them in the browser with Module Federation, and put both live on Vercel.

  • #microfrontends
  • #module-federation
  • #vue
  • #react
  • #vite
  • #vercel

Microfrontends in Simple Terms: Vue + React, Deployed on Vercel

A microfrontend is a piece of a website that is built and deployed as its own app. The page loads it in the browser, at runtime, like a widget from another website.

It's the frontend version of microservices. One team can ship its part without waiting for the others, and each team can even use a different framework.

Live demo: microfronts-host.vercel.app · Code: github.com/iamsomraj/microfronts

What we're building

Two apps:

  • Remote (React): owns a Header component and shares it.
  • Host (Vue): the main page. It loads the Header from the remote and shows it.

The Vue page has a counter. It passes the count to the React component, and React can update it back. The two apps are deployed separately, on two different URLs.

The glue is Module Federation. One app exposes code, another app consumes it, and it all happens in the browser.

When should you use this?

  • Use it when several teams work on one big site, or when you're moving an old app to a new framework bit by bit.
  • Skip it for a small app built by one team. It adds moving parts. A normal component library is simpler.

Step 1: Create the two apps

One folder, two Vite apps, joined by a pnpm workspace:

mkdir microfronts && cd microfronts
pnpm create vite remote --template react
pnpm create vite host --template vue
# pnpm-workspace.yaml
packages:
  - host
  - remote

Add the Module Federation plugin to both:

pnpm --filter host --filter remote add -D @module-federation/vite

Step 2: The remote shares a component

The React component is a normal component. It takes props:

// remote/src/components/Header.jsx
const Header = ({ count = 0, onAdd }) => (
  <div>
    <h2>Hello from React!</h2>
    <p>Count from Vue: {count}</p>
    {onAdd && <button onClick={onAdd}>Add 1 from React</button>}
  </div>
);

export default Header;

Then the remote exposes it:

// remote/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
  plugins: [
    // Vue has no React Fast Refresh, so skip it for shared components.
    react({ exclude: [/node_modules/, /src\/components\//] }),
    federation({
      name: 'remote_app',
      filename: 'remoteEntry.js', // the "menu" other apps read
      exposes: { './Header': './src/components/Header.jsx' },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
      },
    }),
  ],
  build: { target: 'esnext' },
});

When it builds, the remote creates a remoteEntry.js file. Think of it as a menu that says "I have a Header, and here's where to get it."

Step 3: The host loads it

The host tells the plugin where the remote lives. The URL comes from an env var, so it works locally and in production:

// host/vite.config.js
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { federation } from '@module-federation/vite';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd());
  const remoteUrl = env.VITE_REMOTE_URL ?? 'http://localhost:3001';

  return {
    plugins: [
      vue(),
      federation({
        name: 'host_app',
        remotes: {
          remote_app: {
            type: 'module',
            name: 'remote_app',
            entry: `${remoteUrl}/remoteEntry.js`,
          },
        },
        shared: {
          react: { singleton: true },
          'react-dom': { singleton: true },
        },
      }),
    ],
    build: { target: 'esnext' },
  };
});

Vue can't render a React component directly. So we give React an empty <div> and let React render into it:

<!-- host/src/App.vue -->
<script setup>
import { onMounted, ref, watch } from 'vue';

const count = ref(0);
const status = ref('loading');
const target = ref(null);
let root;

const render = async () => {
  const React = await import('react');
  const { default: Header } = await import('remote_app/Header');
  const props = { count: count.value, onAdd: () => count.value++ };
  root.render(React.createElement(Header, props));
};

onMounted(async () => {
  try {
    const { createRoot } = await import('react-dom/client');
    root = createRoot(target.value);
    await render();
    status.value = 'ready';
  } catch {
    status.value = 'error';
  }
});

// When the Vue count changes, render React again with the new props.
watch(count, () => root && render());
</script>

<template>
  <button @click="count++">Count in Vue: {{ count }}</button>
  <p v-if="status === 'error'">The React remote is down.</p>
  <div ref="target"></div>
</template>

Data goes down as props (count) and comes back up through a callback (onAdd). That's all you need for the two apps to talk.

Step 4: Run it locally

Give each app its own port in its package.json:

"dev": "vite --port 3000"   // host
"dev": "vite --port 3001"   // remote

Then add a root script that starts both:

{ "scripts": { "dev": "pnpm --parallel -r dev" } }
pnpm install
pnpm dev

Open http://localhost:3000. You'll see the Vue page with the React box inside it.

Step 5: Deploy both on Vercel

Each app is its own Vercel project, both from the same GitHub repo:

ProjectRoot DirectorySetting
microfronts-remoteremoteCORS headers (below)
microfronts-hosthostEnv var VITE_REMOTE_URL=https://microfronts-remote.vercel.app

The host's page loads files from the remote's domain. Browsers block that unless the remote allows it, so add a CORS header:

// remote/vercel.json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [{ "key": "Access-Control-Allow-Origin", "value": "*" }]
    }
  ]
}

Deploy the remote first, then the host. From then on, every push to main redeploys both.

Here's the best part: change the React Header and redeploy only the remote. The host picks up the new version on the next page load, without being rebuilt.

Things to watch out for

  • Load React once. singleton: true makes both apps share one copy of React. Two copies break hooks.
  • CORS. If the remote's files are blocked, check the header above.
  • The remote can go down. Always wrap the import in try/catch and show a fallback, so the host keeps working.

That's it

Two apps, two frameworks, two deployments, and one page. Try the live demo and click both buttons. Vue and React are keeping the same count.

Share this post