← Back to list

Micro frontends finally made sense when I built one from scratch

There’s a specific kind of dread that comes from a word you’ve heard a hundred times but never actually understood.

Avi Sharma · 2026-06-01 10:33 · 0 claps · 13.4 min read
#micro-frontends #react #vitejs #javascript #web-development
Open on Medium ↗
Wiki topics: GRW · Growth & Analytics 🌐 · Web Development

Micro frontends finally made sense when I built one from scratch

There’s a specific kind of dread that comes from a word you’ve heard a hundred times but never actually understood.

The project is live at mfe-newsroom.vercel.app. Open it, open the Network tab, and watch three remoteEntry.js files arrive from three different origins.

For me that word was “micro frontends.” I’d heard seniors discuss it on calls, casually, the way you talk about something settled. Someone would say “oh yeah, we split that into MFEs” and everyone at the table nodded, including me, who had no idea what was being agreed to. It had that feeling, the feeling of a term engineered to make you feel a half-step behind without ever telling you why.

I read a few blog posts. They all said the same things: “independently deployable,” “team autonomy,” “framework agnostic.” Words that explain nothing. I’d close the tab knowing the vocabulary and none of the mechanics, which is the worst place to be, because now you can sound like you understand it, which means you’ll definitely get caught.

So I did the only thing that’s ever actually worked for me. I built one from scratch. I called it mfe-newsroom: a news dashboard stitched together from four separate apps that don’t know each other exist. And somewhere around hour two of staring at a weather widget that worked perfectly on its own and turned into undefined the moment the shell tried to load it, I finally understood the thing.

This is that story, including the bugs that ate an evening I’m not getting back, and the deploy that finally made “independently deployable” mean something.

The boring problem micro frontends actually solve

Forget the buzzwords for a second. Here’s the real itch.

You have one big React app. One repo, one build, one deploy. It’s fine at first. Then it grows. Now twelve people work on it, the build takes four minutes, and a one-line change to the footer means redeploying the entire thing, including the part another team is mid-rewrite on. Everyone steps on everyone, and the deploy pipeline becomes a queue.

The backend world solved this years ago with microservices: split the monolith into independent services that deploy on their own schedules and talk over a contract. Micro frontends drag that idea, kicking, into the browser. The claim is simple and kind of audacious. What if pieces of your UI were separate apps, separately built, separately deployed, even separately run, and got assembled in the user’s browser at runtime? Not iframes. Not a build step that mashes them together. Actual independent apps, meeting for the first time on the page.

That last part is the one that finally made it click for me, so let me show you what I actually built before I explain how it works.

Four apps that have never met

mfe-newsroom is one dashboard made of four apps, each on its own port, each runnable entirely on its own:

              ┌────────────────────────────────────────┐
              │            SHELL (host)                │
              │   React 18 · TypeScript · Tailwind     │
              │        http://localhost:3000           │
              │                                        │
              │  ┌──────────────┐  ┌────────────────┐  │
              │  │  Headlines   │  │    Weather     │  │
              │  │ Suspense +   │  │ vanilla mount()│  │
              │  │ ErrorBoundary│  ├────────────────┤  │
              │  │              │  │   Bookmarks    │  │
              │  └──────┬───────┘  └───────┬────────┘  │
              └─────────┼──────────────────┼───────────┘
        runtime import()│                  │
        ┌───────────────┼────────┬─────────┴──────────┐
        ▼               ▼        ▼                    ▼
  ┌─────────────┐  ┌───────────────┐  ┌──────────────────────┐
  │mfe-headlines│  │ mfe-bookmarks │  │     mfe-weather      │
  │ React + TS  │  │ React + TS    │  │ VANILLA JS, no React │
  │   :3001     │  │   :3002       │  │        :3003         │
  └─────┬───────┘  └───────▲───────┘  └──────────────────────┘
        │                  │
        │  CustomEvent('article-saved', { detail })
        └──────────────────┘
  • **shell** (port 3000) is the host. It's a React app whose entire job is layout: a navbar, a live status bar, and a grid. It owns almost no business logic. It loads the others.
  • **mfe-headlines** (3001) fetches top tech headlines and lets you save one.
  • **mfe-bookmarks** (3002) shows the articles you saved.
  • **mfe-weather (3003) shows the current weather, and it's deliberately built with zero React, zero TypeScript, zero Tailwind.** Pure vanilla JS and hand-written CSS. More on why that's the most important app in the project later.

Each one has its own index.html, its own dev server, its own package.json. You can cd mfe-headlines && npm run dev and get a fully working headlines app on localhost:3001 with nothing else running. That's the "independently runnable" claim, and it's not theoretical. It's how I developed each one.

The question is: how does the shell pull three other running apps into one page without importing them at build time?

The remoteEntry.js trick that makes the whole thing click

Let me slow down here, because this part is the one that actually made it click.

I’m using Vite with @originjs/vite-plugin-federation. On each remote, the plugin config says "here's my name, and here's what I'm willing to hand out." Headlines looks like this:

federation({
  name: 'mfe_headlines',
  filename: 'remoteEntry.js',
  exposes: {
    './HeadlinesApp': './src/HeadlinesApp.tsx',
  },
  shared: ['react', 'react-dom'],
})

When you build that remote, the plugin emits a file called remoteEntry.js, a tiny manifest that says "I export a thing called ./HeadlinesApp, and here's how to fetch the actual code for it." It's a menu, not the meal.

The shell, on the other side, lists where those menus live:

federation({
  name: 'shell',
  remotes: {
    mfe_headlines: 'http://localhost:3001/assets/remoteEntry.js',
    mfe_bookmarks: 'http://localhost:3002/assets/remoteEntry.js',
    mfe_weather:   'http://localhost:3003/assets/remoteEntry.js',
  },
  shared: ['react', 'react-dom'],
})

And then, this is the magic, the shell loads a remote with what looks like a completely ordinary dynamic import:

const HeadlinesApp = React.lazy(() => import('mfe_headlines/HeadlinesApp'))

That import('mfe_headlines/HeadlinesApp') is not resolved at build time. There is no copy of Headlines inside the shell's bundle. At runtime, in the browser, that import goes and fetches remoteEntry.js from localhost:3001, reads the menu, then fetches the real chunk for HeadlinesApp. The apps are stitched together in the browser, over HTTP, from three different origins.

If you don’t believe me, and I didn’t at first, open the Network tab and reload the shell. You’ll watch three separate remoteEntry.js files come down from three different ports. That was the moment it stopped being a buzzword for me.

One detail worth pausing on: shared: ['react', 'react-dom']. Without it, the shell and each React remote would each ship their own React, and you'd have multiple Reacts fighting over the same tree, a classic way to get the "invalid hook call" error. shared tells federation to negotiate a single copy at runtime. Notice mfe-weather declares shared: [], because it has no React to share. It needs nothing from anyone.

How two apps gossip without being introduced

Here’s the next thing that confused me: if Headlines and Bookmarks are separate apps with separate bundles and separate state, how does saving an article in one make it appear in the other?

The lazy answer is “a shared store.” Redux, Zustand, a context provider in the shell, something they both reach into. But the moment you do that, your “independent” apps share a dependency and a data shape, and you’ve quietly rebuilt a piece of the monolith. They’re now coupled. Deploy one, you’d better not have changed the store’s shape.

I used the dumbest possible thing instead, and it turned out to be the right thing: the browser’s own event system. Here’s the entire “event bus”:

export const eventBus = {
  emit: (event, detail) =>
    window.dispatchEvent(new CustomEvent(event, { detail })),
  on: (event, handler) =>
    window.addEventListener(event, handler),
  off: (event, handler) =>
    window.removeEventListener(event, handler),
}

That’s it. It’s a thin wrapper over window.dispatchEvent. When you hit Save Article in Headlines, it fires an event and updates its own local "saved" set:

const handleSave = (article: Article) => {
  eventBus.emit('article-saved', article)
  setSavedUrls((prev) => new Set(prev).add(article.url))
}

Bookmarks, which has never heard of Headlines, just listens on window:

useEffect(() => {
  const handler = (e: CustomEvent<Article>) => {
    const article = e.detail
    setSaved((prev) =>
      prev.some((a) => a.url === article.url) ? prev : [article, ...prev],
    )
  }
  eventBus.on('article-saved', handler)
  return () => eventBus.off('article-saved', handler)
}, [])

The two apps never import each other. They never touch the same state. They agree on exactly two things: the string 'article-saved' and the shape of the payload. That's the contract, and the contract is the only thing they share. Headlines could be rewritten in Vue tomorrow and Bookmarks wouldn't notice, as long as it kept firing that event.

I’ll be honest about the trade-off, because I know what you’re thinking: a stringly-typed event with no schema enforcement is fragile. Rename the event or change the payload and things silently stop working with no compiler to catch you. I’ll come back to that. But for proving the concept, the decoupling it buys is exactly the point.

The vanilla widget that proves it’s not a React trick

When I first got the React remotes working, a nagging voice said: sure, but this only works because everything’s React. You’re just doing fancy lazy-loading.

So I built mfe-weather to kill that voice. It has no React. No TypeScript. No Tailwind. It's a single file of plain JS that exports one imperative function, and that function just sets innerHTML on a div:

export function mount(containerId) {
  const container = document.getElementById(containerId)
  if (!container) return

  container.innerHTML = `<div class="weather-card">…Loading…</div>`
  fetch(WEATHER_URL)
    .then((r) => r.json())
    .then((data) => {
      const { temperature, weathercode } = data.current_weather
      const [icon, label] = describe(weathercode)
      container.innerHTML = `<div class="weather-card">${icon} ${temperature}°C, ${label}</div>`
    })
    .catch(() => {
      container.innerHTML = `<div class="weather-card">⚠️ unavailable</div>`
    })
}

describe is a little lookup table mapping WMO weather codes to emoji. 0 is ☀️ Clear sky, 95 is ⛈️ Thunderstorm, because the Open-Meteo API returns weather as integer codes and someone has to translate. This is about as far from a React component as you can get and still be in a browser.

The shell can’t <WeatherWidget /> that. There's no component. So it wraps the imperative function in a tiny React adapter that gives the vanilla widget a div to live in, then calls mount() on it:

import('mfe_weather/WeatherWidget').then((mod) => {
  const mount = mod.mount ?? mod.default?.mount
  mount('weather-container')
  setPhase('ready')
})

A React host rendering a vanilla-JS remote through a mount() call. Different language, no framework, no shared anything, and it sits on the same page as the React panels like it belongs there. That's the proof. Micro frontends aren't a React feature. The contract is "fetch a module, call its export." What's behind that export is nobody's business.

(That mod.mount ?? mod.default?.mount line is not me being defensive for fun. It's a scar. Hold that thought.)

The two bugs I didn’t see coming

Everything above reads clean because I’m telling it after the fact. The evening itself was not clean. Two bugs in particular took real time, and both taught me the same lesson from different angles, which is why I think they’re the most valuable part of this whole project.

Bug 1: the widget that worked alone and vanished in the shell

The weather widget was perfect standalone. localhost:3003, beautiful glassmorphism card, current temperature, the whole thing. I plugged it into the shell and got nothing. No card. The widget's mount was undefined.

I did the thing you do. I console.log'd the imported module expecting { mount: ƒ }. Instead I got this:

{ default: { mount: ƒ }, __esModule: true }

My export { mount } had arrived in the shell as mod.default.mount. The function was right there, just one level deeper than where I was reaching for it. vite-plugin-federation had re-wrapped my clean named export under a default key on the way across the federation boundary. So mod.mount was undefined, and standalone, where I imported it as a normal ESM module, mod.mount was the function. Same code, two different shapes, depending on how it was loaded.

The fix is one line, and it’s anticlimactic:

const mount = mod.mount ?? mod.default?.mount

But the lesson wasn’t anticlimactic at all. I’d been thinking of federation as “the same module, just fetched from somewhere else.” It’s not. Federation changes the shape your module arrives in, not just its address. The remote is a different code path, and a thing you proved works standalone proves nothing about how it behaves federated. That sentence is the whole reason this bug was worth having.

Bug 2: the buttons that forgot how to look like buttons

Second bug, same shape of lesson. The React remotes loaded fine in the shell. Content showed up, events fired, everything worked. But the styling was wrong. Badges were unstyled. Buttons looked like raw links. In standalone mode on port 3001, Headlines was pixel-perfect. In the shell, it was visually naked.

I lost a chunk of time here because “it works standalone” kept pulling my attention to the shell. Surely the host was stripping the styles somehow. It wasn’t.

The remotes imported their Tailwind CSS in main.tsx, the standalone entry file. The file that boots the app when you run it on its own. But main.tsx is not what federation loads. Federation loads the exposed module: HeadlinesApp.tsx. And HeadlinesApp.tsx never imported the stylesheet. So when the shell pulled in the exposed component, the CSS chunk it depended on was never even emitted. The styles existed only on a code path the shell never walked.

The fix was to import the stylesheet inside the exposed file:

// At the top of HeadlinesApp.tsx, the exposed module
import './index.css'

I left the why in a comment, because future-me deserves to not relearn this:

// Import styles in the EXPOSED module so the remote's Tailwind CSS ships with
// the federated chunk and is injected into the shell (the standalone main.tsx
// import only covers port 3001). Without this, the shell renders this MFE with
// only the host's CSS, so remote-only utilities are missing.

Same lesson as Bug 1, wearing a different costume: stop thinking about what runs when your app boots standalone. Start thinking about what executes in the federation context. They are genuinely different code paths. main.tsx runs standalone; HeadlinesApp.tsx runs federated. Anything the federated path needs, exports, styles, side effects, has to live on that path.

The bug I avoided by getting paranoid

One more, smaller, that I’m including because it’s the kind of thing the first two trained me to expect. The vanilla weather widget calls document.getElementById('weather-container') to find its home. My first instinct in the React wrapper was to conditionally render that div, show it only when the widget is ready, otherwise show a spinner.

That would’ve been a bug. The vanilla widget opens with const container = document.getElementById(containerId); if (!container) return. If React hasn't rendered the div yet, that guard fires and mount() quietly does nothing. No error, just an empty panel. So the container has to always be in the DOM; I just hide it until it's filled:

{/* Mount target, kept in the DOM so getElementById works; hidden until ready. */}
<div id="weather-container" className={cn(phase !== 'ready' && 'hidden')} />

The loading and error states render as siblings, never inside that div, because mount() overwrites the container's innerHTML and would happily blow away any React-controlled content I put there. React owns the page; the vanilla widget owns exactly one div and nothing else. Drawing that border carefully is the price of mixing imperative code into a declarative tree.

Then I deployed it, and “independently deployable” stopped being a slogan

Running four apps on localhost proves they’re independently runnable. Deploying is where you find out if “independently deployable” was ever real. So I put it on Vercel, and the shape of the deploy is the whole lesson.

There is no single deploy. There’s no “the project.” There are four separate Vercel projects, all pointing at the same Git repo, each with a different Root Directory: one for shell, one for each remote. Four apps, four deploy buttons, four production URLs. The first time I clicked "Deploy" on just the headlines folder and got a standalone, live headlines app at its own domain, the abstraction finally felt physical.

But the shell had hardcoded http://localhost:3001/assets/remoteEntry.js, which is useless in production. The fix was to make the remote URLs come from env vars, with the localhost ports as fallbacks so local dev keeps working with no config:

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')

  const headlines = env.VITE_HEADLINES_URL ?? 'http://localhost:3001'
  const bookmarks = env.VITE_BOOKMARKS_URL ?? 'http://localhost:3002'
  const weather   = env.VITE_WEATHER_URL   ?? 'http://localhost:3003'
  const remoteEntry = (base) => `${base.replace(/\/$/, '')}/assets/remoteEntry.js`
  return {
    plugins: [react(), federation({
      name: 'shell',
      remotes: {
        mfe_headlines: remoteEntry(headlines),
        mfe_bookmarks: remoteEntry(bookmarks),
        mfe_weather:   remoteEntry(weather),
      },
      shared: ['react', 'react-dom'],
    })],
    // ...
  }
})

Deploy the three remotes first, copy their URLs into the shell’s Vercel env vars (VITE_HEADLINES_URL and friends), then deploy the shell last. One gotcha that cost me a confused minute: Vite inlines env vars at build time, so setting them after the first deploy does nothing until you redeploy. The values are baked into the bundle, not read at runtime.

And then the scar that’s pure deployment, the one localhost hides from you. On my machine the remotes were all localhost, friendly neighbors. In production they're three genuinely different origins (mfe-newsroom-headlines.vercel.app and so on), and the shell is a fourth. The browser does not let one origin fetch a script from another by default. Every remote panel went red, and the Network tab showed remoteEntry.js blocked by CORS.

The fix lives in each remote’s vercel.json, not the shell's:

{
  "outputDirectory": "dist",
  "headers": [
    {
      "source": "/assets/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "*" },
        { "key": "Access-Control-Allow-Methods", "value": "GET, OPTIONS" }
      ]
    }
  ]
}

Each remote has to consent to being fetched cross-origin by serving that header on its /assets/*. That requirement was completely invisible on localhost and completely unavoidable in production. It's the most honest thing the deploy taught me: independence has a cost, and the cost is that the seams between your apps are now real network boundaries, with all the CORS, latency, and versioning that implies.

One thing that survived the move untouched: clicking Save Article still instantly populates Bookmarks. The article-saved event rides window on the shell's page, so all the panels share one origin there regardless of where their code was fetched from. The event bus didn't care that the apps now live on four different domains.

What I’d build differently if this were real

This is a learning project, and the mental models it builds are real even though the project isn’t production-grade.

The event bus needs a versioned, typed contract. Right now 'article-saved' is a magic string with no runtime validation, exactly the fragility I admitted to earlier. A real system would version the event and validate the payload, so a remote can't silently break its consumers.

And while it now genuinely deploys, the operational story is still naive. The shell learns each remote’s URL from a build-time env var, which means a remote can’t move without a shell rebuild, and there’s no story for rolling one back or shipping a breaking change to the contract without a coordinated deploy. That, plus the question of shared design tokens versus duplicated component libraries, is where the real discipline lives: deciding, on purpose, what to share and what to duplicate. I now have opinions about that, which is more than I had a week ago.

The actual takeaway

Here’s what nobody told me, and what I’d have wanted to know before I started.

The hard part of micro frontends isn’t Module Federation. The config is twenty lines. The hard part is that “the same code” stops being one thing. A module that’s perfect standalone can arrive at its destination reshaped, unstyled, looking for a DOM node that isn’t there, or blocked at a network boundary that didn’t exist on your laptop. Federation introduces a second code path and a second environment, and your standalone path was never walking either one. Every bug traced back to the same assumption: that ‘it works on 3001’ meant ‘it works.’ It doesn’t. It means it works on 3001.

I still don’t love that “micro frontends” gets thrown around to make people feel behind. But I’ll admit the thing it points at is real, and kind of elegant once you’ve watched three remoteEntry.js files land in your Network tab from three different domains and assemble themselves into one page.

So the next time I hear a senior say “we just split that into MFEs” and the table nods, I’ll nod too. Except now the nod means something.

The live version is at mfe-newsroom.vercel.app if you want to open the Network tab and watch the remoteEntry files land.

If you found this useful, I write about engineering deep-dives and things I build to understand how stuff works. Find me on GitHub, Medium or X.


메타데이터
post_id
b09d1ab2acfd
slug
micro-frontends-finally-made-sense-when-i-built-one-from-scratch-b09d1ab2acfd
url
https://medium.com/@AviSharmaaa/micro-frontends-finally-made-sense-when-i-built-one-from-scratch-b09d1ab2acfd
canonical_url
https://medium.com/@AviSharmaaa/micro-frontends-finally-made-sense-when-i-built-one-from-scratch-b09d1ab2acfd
author_url
https://medium.com/@AviSharmaaa
status
ok
fetched_at
2026-07-15 12:46:07