Migrating a Monolith to Micro-Frontends Incrementally #

A big-bang frontend rewrite fails for the same reason every big-bang rewrite fails — this page extracts one capability at a time behind a shell that serves both the monolith and the extracted remotes, so every step ships and every step is reversible.

The order matters more than the technique. Teams that begin by extracting the shared header spend six months on the highest-coupling, lowest-value part of the system. Teams that begin with a self-contained capability at the edge ship something real in weeks. It applies the seam-finding work in defining application boundaries.

The order that makes an incremental migration finish Each stage is independently shippable and independently revertible, and the highest-coupling work is deliberately left until its consumers have already moved. Four stages, in the order that works Shell in front monolith untouched proves routing and auth First extraction edge capability monolith is the fallback Steady extraction one capability at a time metrics reviewed each time Shared layer last header and design system or deliberately never Every stage ships to production on its own; none of them requires the next one to start.
The instinct is to start at the last stage, which is why so many of these migrations stall in their first quarter.

Prerequisites #

Step 1 — Pick the first capability by coupling, not by importance #

The first extraction is a proof, not a milestone. Choose it to maximise the chance of finishing.

Good first candidates share three properties: few other parts of the application import them, they own a distinct URL space, and they have a small, obvious data contract. An account settings section, a help centre, an admin report — anything at the edge of the dependency graph.

Bad first candidates are the ones teams instinctively pick. The header couples to every page. The design system couples to everything by definition. The checkout flow is high-value and high-risk, which is the worst combination for a first attempt.

# A crude but effective coupling measure: who imports this directory?
grep -rl "from '.*/features/settings" src --include=*.tsx | wc -l    #   4 files
grep -rl "from '.*/components/Header" src --include=*.tsx | wc -l    # 212 files

Step 2 — Put a shell in front of the monolith, without splitting it #

Why the shell ships before anything moves Introducing the shell as a no-op deploy separates the infrastructure risk from the extraction risk, so a failure has one plausible cause. Extract first, shell later Routing, auth and layout all new at once First extraction proves nothing in isolation A failure could be any of four things Revert means undoing everything Shell first, extract second Shell ships rendering the untouched monolith Routing and auth proven on real traffic First extraction changes exactly one thing Each step reverts independently
The no-op deploy feels like a wasted week and is what makes every subsequent step diagnosable.

Before extracting anything, introduce the shell and have it render the untouched monolith. This step changes no product behaviour and is the one that makes everything after it reversible.

// shell/src/App.jsx — step 2: the monolith is simply the first "remote"
export function App() {
  return (
    <Layout>
      <Routes>
        {/* Everything still goes to the monolith. Nothing has moved. */}
        <Route path="/*" element={<MonolithRoutes />} />
      </Routes>
    </Layout>
  );
}

Ship this on its own and let it run for a week. It proves the shell’s routing, authentication and layout work against real traffic before any extraction depends on them, and if it goes wrong the revert is a single deploy with no data or contract implications.

Resist adding capability to the shell here. Its job is to render the monolith and nothing else; every feature added now becomes a constraint on every later extraction.

Step 3 — Extract the first capability behind a route #

Move the chosen capability into its own repository and build, expose it as a route subtree, and point one path prefix at it.

// shell/src/App.jsx — step 3: one prefix moves, everything else is unchanged
const SettingsRoutes = lazy(() => loadRemote('settings', './Routes'));

export function App() {
  return (
    <Layout>
      <Routes>
        <Route path="/settings/*" element={
          <RemoteErrorBoundary remote="settings" fallback={<MonolithRoutes />}>
            <Suspense fallback={<PageSkeleton />}><SettingsRoutes /></Suspense>
          </RemoteErrorBoundary>
        } />
        <Route path="/*" element={<MonolithRoutes />} />
      </Routes>
    </Layout>
  );
}

The fallback is the interesting part: if the extracted remote fails to load, the boundary renders the monolith’s version of the same route. The old code is still there, so the safest possible fallback is the thing you just replaced.

Keep the monolith’s copy for at least one full release cycle. Deleting it the same week removes your ability to revert without a deploy.

Step 4 — Share dependencies deliberately from the start #

The monolith already has a fixed React version, and it is now the de facto standard for the whole graph. Make that explicit rather than discovering it.

// The monolith becomes the host, so it registers shared dependencies eagerly.
new ModuleFederationPlugin({
  name: 'shell',
  remotes: { settings: 'settings@https://cdn.example.com/settings/remoteEntry.js' },
  shared: {
    react: { singleton: true, requiredVersion: deps.react, eager: true },
    'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: true },
    'react-router-dom': { singleton: true, requiredVersion: deps['react-router-dom'], eager: true, strictVersion: true },
  },
});

Extracted remotes mirror this with eager: false. Getting this right at the first extraction saves a great deal of pain later, because every subsequent remote inherits the same contract — the negotiation rules are covered in managing shared dependencies at runtime.

Upgrading the monolith’s framework version now becomes a coordinated change. That is a real cost of the architecture, and it is better to know it at extraction one than at extraction six.

Step 5 — Extract the shared layer last, not first #

Choosing extraction order by inbound coupling Counting how many files import a capability predicts extraction cost better than any judgement about its architectural importance. Capability Inbound imports Extract Account settings 4 files first — ideal candidate Help centre 7 files early Checkout flow 38 files middle, once patterns are proven Navigation header 212 files last, or leave it Design system everything last, as a federated remote
The two rows teams instinctively start with are the two that belong at the bottom.

The header, navigation and design system are the parts everything depends on, which is exactly why they should move last.

Until then, let the monolith keep owning them and let extracted remotes consume them as a package. It is not elegant — the remote takes a build-time dependency on the monolith’s component library — and it is far less risky than trying to extract a component that 212 files import while those files are also moving.

Once most capabilities have been extracted, the shared layer’s consumers are a handful of independent remotes rather than a monolith, and extracting it becomes a normal piece of work. At that point it becomes a federated design system, as described in design system and theming across remotes.

The interim state — remotes consuming a package published from the monolith — is stable enough to live in for a year, and many organisations correctly stop there.

Step 6 — Define the end state before you are halfway #

Migrations without a defined end state do not end. They reach a point where the remaining monolith is the hard part, momentum fades, and the organisation runs a hybrid indefinitely by accident rather than by choice.

Write down which capabilities will be extracted and which will not, and be willing for the answer to include “the rest stays”. A monolith containing three low-change capabilities, behind a shell, alongside five extracted remotes, is a perfectly reasonable steady state — and choosing it deliberately is very different from drifting into it.

The signal that a capability should not be extracted is that it changes rarely and is owned by nobody in particular. Extraction buys independent deployment, and a capability nobody deploys gains nothing from it while still paying the runtime and operational cost.

Step 7 — Track whether the migration is delivering what it promised #

The migration was justified by teams blocking each other. Measure that, not the number of remotes extracted.

// scripts/migration-metrics.mjs — the numbers that justify continuing
console.log({
  deploysPerWeek: await deployFrequency({ since: '30d' }),          // should rise
  leadTimeHours: await medianLeadTime({ since: '30d' }),            // should fall
  crossTeamPRs: await pullRequestsTouchingMultipleOwners('30d'),    // should fall
  remotesExtracted: await countRemotes(),                           // a means, not an end
});

If deploy frequency is not rising and lead time is not falling after two or three extractions, the boundaries are probably wrong — most often horizontal rather than vertical, so every change still spans several remotes. That is a finding worth acting on before extraction four, and it is far cheaper to correct at that point than at extraction ten.

Verification #

Troubleshooting #

Symptom: the first extraction took three times as long as estimated.

Diagnosis: the chosen capability had more inbound dependencies than the plan assumed. Fix: measure imports before choosing, and if the count is high, pick something further out and come back to it.

Symptom: every change still touches the monolith and a remote.

Diagnosis: the boundary is horizontal — the remote owns a layer rather than a capability. Fix: revisit the seam using the vertical-split reasoning in defining application boundaries.

Symptom: two React copies after the first extraction.

Diagnosis: the monolith does not register React eagerly, so the remote loaded its own. Fix: set eager: true on the host side and false on remotes.

Symptom: the migration stalled at 60 per cent.

Diagnosis: no defined end state, so the remaining work has no completion criterion. Fix: decide explicitly what stays in the monolith and declare the migration finished when the list is empty.