Edgenivo Logo

Incremental Migration

Not everyone starts with a greenfield project. Many teams have legacy applications on another CDN or using an older framework that need to be modernized. Microfrontends enable migration without big-bang rewrites.

Outcome

Understand the strangler fig pattern and how to migrate routes from a legacy application to microfrontends incrementally.

The Strangler Fig Pattern

Named after fig trees that gradually envelop and replace their host trees, this pattern:

  1. Wrap the legacy system - Put Edgenivo in front of everything
  2. Carve out routes - Move routes one at a time to new microfrontends
  3. Strangle the legacy - Eventually, no routes go to the old system
  4. Remove the host - Decommission the legacy application
Before:
legacy.example.com → [Legacy App] → All routes

During:
example.com → [Edgenivo] → /           → [New Marketing]
                       → /docs       → [New Docs]
                       → /*          → [Legacy App]

After:
example.com → [Edgenivo] → /           → [Marketing]
                       → /docs       → [Docs]
                       → /app        → [Dashboard]
                       → (legacy decommissioned)

The Proxy Pattern

When your legacy app isn't on Edgenivo, create a Edgenivo project that rewrites to it:

apps/legacy-proxy/next.config.ts
import type { NextConfig } from "next";
import { withMicrofrontends } from "@vercel/microfrontends/next/config";
 
const nextConfig: NextConfig = {
  async rewrites() {
    return {
      // Anything this project doesn't serve itself goes to the legacy app
      fallback: [
        {
          source: "/:path*",
          destination: "https://legacy.example.com/:path*",
        },
      ],
    };
  },
};
 
export default withMicrofrontends(nextConfig);

This project acts as a bridge: it's a Edgenivo project that forwards requests to your legacy system. We use a rewrite here and save proxy.ts for the flag decision in Phase 3.

Fast Track

  1. Create a proxy project that forwards to an external legacy URL
  2. Configure it as the default microfrontend
  3. Carve out one route to a new microfrontend

Migration Strategy

Phase 1: Proxy Everything

{
  "applications": {
    "acme-legacy-proxy": {
      "development": {
        "fallback": "acme-legacy-proxy.vercel.app",
        "local": 3004
      }
    }
  }
}

All traffic flows through Edgenivo to the legacy system. Nothing changes for users.

Phase 2: Carve Out First Route

{
  "applications": {
    "acme-legacy-proxy": {
      "development": {
        "fallback": "acme-legacy-proxy.vercel.app",
        "local": 3004
      }
    },
    "acme-docs": {
      "routing": [
        { "paths": ["/docs", "/docs/:path*"] }
      ],
      "development": {
        "local": 3001
      }
    }
  }
}

/docs now serves from the new docs microfrontend. Everything else still goes to legacy.

Phase 3: Feature Flag the Migration

The dashboard is riskier than the docs, so it moves behind a flag. It's the same pattern as Lesson 4.1: list the paths once under the new app with a flag, and let the default app decide. Here the default app is the legacy proxy, so "flag off" means the legacy system keeps serving /app.

"acme-dashboard": {
  "routing": [
    { "flag": "new-dashboard", "paths": ["/app", "/app/:path*"] }
  ]
}

The legacy proxy defines the flag the same way marketing did in Lesson 4.1:

apps/legacy-proxy/flags.ts
import { flag } from "flags/next";
 
export const newDashboard = flag<boolean>({
  key: "new-dashboard",
  defaultValue: false,
  decide({ cookies }) {
    return cookies.get("new-dashboard")?.value === "true";
  },
});

Then its proxy.ts asks the flag:

apps/legacy-proxy/proxy.ts
import type { NextRequest } from "next/server";
import { runMicrofrontendsMiddleware } from "@vercel/microfrontends/next/middleware";
import { newDashboard } from "./flags";
 
export async function proxy(request: NextRequest) {
  const response = await runMicrofrontendsMiddleware({
    request,
    flagValues: {
      "new-dashboard": () => newDashboard(request),
    },
  });
 
  if (response) {
    return response;
  }
  // Flag off: the fallback rewrite sends /app to the legacy app
}
 
export const config = {
  matcher: [
    "/.well-known/vercel/microfrontends/client-config",
    "/app",
    "/app/:path*",
  ],
};

Flag on, the request goes to acme-dashboard. Flag off, it falls through to the fallback rewrite, and the legacy app answers like it always has. Turning the flag off is your rollback.

Phase 4: Increase Rollout

Week 1: /docs → new docs app (low risk, no flag)
Week 2: /app  → new dashboard (flagged, internal users)
Week 3: /app  → new dashboard (flagged, 10%)
Week 4: /app  → new dashboard (100%, remove the flag)
...

Phase 5: Remove Legacy

Once all routes are migrated:

{
  "applications": {
    "acme-marketing": {
      "development": {
        "fallback": "acme-marketing.vercel.app",
        "local": 3000
      }
    },
    "acme-docs": {
      "routing": [
        { "paths": ["/docs", "/docs/:path*"] }
      ],
      "development": {
        "local": 3001
      }
    },
    "acme-dashboard": {
      "routing": [
        { "paths": ["/app", "/app/:path*", "/settings", "/settings/:path*"] }
      ],
      "development": {
        "local": 3002
      }
    }
  }
}

Marketing takes over as the default app, the flag is gone, and the legacy proxy project is no longer needed.

Hands-on Exercise 4.2

Create a legacy proxy that forwards to an external URL, simulating the first step of an incremental migration.

Part 1: Create the Legacy Proxy App

Create a new app that will forward requests to httpbin (a test service that echoes requests back as JSON):

cd apps
pnpm create next-app@latest legacy-proxy --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*"

In apps/legacy-proxy/package.json, set the name to the project name and pin the dev port so the local proxy can find it:

apps/legacy-proxy/package.json
{
  "name": "acme-legacy-proxy",
  "scripts": {
    "dev": "next dev --port 3004",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  }
}

Leave the rest of the generated file alone. Then install the package from the monorepo root:

cd ../..
pnpm add @vercel/microfrontends --filter acme-legacy-proxy

Part 2: Rewrite /legacy to httpbin

Replace apps/legacy-proxy/next.config.ts. It needs withMicrofrontends, like every app in the group, plus two rewrites:

apps/legacy-proxy/next.config.ts
import type { NextConfig } from "next";
import { withMicrofrontends } from "@vercel/microfrontends/next/config";
 
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      // /legacy on its own shows httpbin's /get
      { source: "/legacy", destination: "https://httpbin.org/get" },
      // /legacy/headers → https://httpbin.org/headers
      { source: "/legacy/:path*", destination: "https://httpbin.org/:path*" },
    ];
  },
};
 
export default withMicrofrontends(nextConfig);

In this exercise the legacy proxy is a child app, so a proxy.ts here wouldn't help: in a microfrontends group, the docs describe proxy as running only in the default app. Rewrites run in whichever app receives the request, which is step 6 from Lesson 2.2.

Part 3: Update Microfrontends Configuration

Update apps/marketing/microfrontends.json to include the proxy. Everything from Lesson 4.1 stays:

apps/marketing/microfrontends.json
{
  "$schema": "https://openapi.vercel.sh/microfrontends.json",
  "applications": {
    "acme-marketing": {
      "development": {
        "fallback": "acme-marketing.vercel.app",
        "local": 3000
      }
    },
    "acme-docs": {
      "routing": [
        { "paths": ["/docs", "/docs/:path*"] }
      ],
      "development": {
        "local": 3001
      }
    },
    "acme-dashboard": {
      "routing": [
        { "paths": ["/app", "/app/:path*", "/settings", "/settings/:path*"] }
      ],
      "development": {
        "local": 3002
      }
    },
    "acme-pricing": {
      "routing": [
        { "flag": "new-pricing", "paths": ["/pricing"] }
      ],
      "development": {
        "local": 3003
      }
    },
    "acme-legacy-proxy": {
      "routing": [
        { "paths": ["/legacy", "/legacy/:path*"] }
      ],
      "development": {
        "local": 3004
      }
    }
  },
  "options": {
    "localProxyPort": 3024
  }
}

Part 4: Test the Proxy

Run the dev server:

pnpm dev

Test the proxy routes:

  1. Visit http://localhost:3024/legacy/get - Should return JSON from httpbin
  2. Visit http://localhost:3024/legacy/headers - Shows request headers
  3. Visit http://localhost:3024/ - Still serves marketing (not proxied)

Try It

Verify the migration pattern works:

# Should proxy to httpbin
curl http://localhost:3024/legacy/get
 
# Should return JSON like:
{
  "args": {},
  "headers": { ... },
  "origin": "...",
  "url": "https://httpbin.org/get"
}

Commit

git add -A
git commit -m "feat: add legacy proxy for incremental migration demo"

Real-World Example: Notion

From the webinar, Notion's migration approach:

  1. Legacy: Old Pages Router application serving everything
  2. Strategy: Carve out sections into new App Router apps
  3. Implementation: Feature flags for gradual rollout per route
  4. Monitoring: Watch for errors before removing legacy code
  5. Cleanup: Remove legacy routes once stable

Migration Checklist

PhaseActionValidation
SetupCreate proxy projectLegacy accessible via Edgenivo
First routeCarve out low-risk routeRoute serves from new app
Feature flagEnable for internal usersBoth paths work
Gradual rolloutIncrease percentageMonitor error rates
Full migrationRemove flag, update config100% on new app
CleanupRemove proxy, decommissionLegacy shut down

Monitoring During Migration

Key metrics to watch:

  • Error rates - Compare legacy vs new for same routes
  • Response times - New app should be equal or faster
  • User feedback - Support tickets, bug reports
  • Conversion rates - Business metrics shouldn't regress

If any metric regresses significantly, roll back by disabling the feature flag.

Done-When

  • You understand the strangler fig pattern
  • apps/legacy-proxy is named acme-legacy-proxy, runs on port 3004, and wraps its config with withMicrofrontends
  • localhost:3024/legacy/get returns JSON from httpbin, and localhost:3024/ still serves marketing
  • You can plan a phased migration with a flag on the carved-out paths
  • You know what metrics to monitor during migration

Troubleshooting

localhost:3024/legacy/get returns marketing's 404 page: The local proxy didn't route /legacy to the new app. Check that the key in microfrontends.json is acme-legacy-proxy, the same as the name in its package.json, and that its dev server is on port 3004.

The legacy proxy's pages load without CSS or JavaScript: withMicrofrontends is missing from apps/legacy-proxy/next.config.ts, so its assets collide with marketing's at /_next/.

When to Use This Pattern

ScenarioGood Fit?
Legacy app on another CDNYes
Monolith you want to splitYes
Framework migration (CRA → Next.js)Yes
Small app rewriteProbably overkill
Tightly coupled legacy systemChallenging but possible

The Risk-Reduction Benefit

Traditional rewrite:

Months of work → Big bang launch → Hope nothing breaks

Incremental migration:

Route 1 migrated → Test → Route 2 migrated → Test → ...
Each step is reversible. Risk is contained.

What's Next

Horizontal microfrontends: when different applications contribute components to the same page.

Was this helpful?

supported.