Vercel 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 Vercel 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 → [Vercel] → /           → [New Marketing]
                       → /docs       → [New Docs]
                       → /*          → [Legacy App]

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

The Proxy Pattern

When your legacy app isn't on Vercel, create a proxy project:

apps/legacy-proxy/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  // Proxy all requests to the legacy application
  const legacyUrl = new URL(
    request.nextUrl.pathname + request.nextUrl.search,
    "https://legacy.example.com"
  );
 
  return NextResponse.rewrite(legacyUrl);
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

This project acts as a bridge: it's a Vercel project that forwards requests to your legacy system.

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": "http://localhost:3000",
        "local": 3000
      }
    }
  }
}

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

Phase 2: Carve Out First Route

{
  "applications": {
    "@acme/legacy-proxy": {
      "development": {
        "fallback": "http://localhost:3000",
        "local": 3000
      }
    },
    "@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

// In legacy-proxy middleware
const pathname = request.nextUrl.pathname;
 
if (pathname.startsWith("/app")) {
  const useNewDashboard = await checkFeatureFlag("new-dashboard");
  if (useNewDashboard) {
    // Rewrite to new dashboard microfrontend
    return NextResponse.rewrite(new URL("/app-new" + pathname.slice(4), request.url));
  }
}
 
// Fall through to legacy
return NextResponse.rewrite(new URL(pathname, "https://legacy.example.com"));

Phase 4: Increase Rollout

Week 1: /docs → new docs app (feature flagged, 10%)
Week 2: /docs → new docs app (50%)
Week 3: /docs → new docs app (100%, remove flag)
Week 4: /app → new dashboard (feature flagged, 10%)
...

Phase 5: Remove Legacy

Once all routes are migrated:

{
  "applications": {
    "@acme/marketing": {
      "development": {
        "fallback": "http://localhost:3000",
        "local": 3000
      }
    },
    "@acme/docs": {
      "routing": [
        { "paths": ["/docs", "/docs/:path*"] }
      ],
      "development": {
        "local": 3001
      }
    },
    "@acme/dashboard": {
      "routing": [
        { "paths": ["/app", "/app/:path*"] }
      ],
      "development": {
        "local": 3002
      }
    }
  }
}

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 proxy requests to httpbin (a test service):

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

Part 2: Configure the Proxy Middleware

Create apps/legacy-proxy/middleware.ts:

apps/legacy-proxy/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
 
  // Proxy /legacy/* paths to httpbin for demonstration
  if (pathname.startsWith("/legacy")) {
    const targetPath = pathname.replace("/legacy", "");
    const legacyUrl = new URL(
      targetPath || "/get",
      "https://httpbin.org"
    );
 
    console.log(`[proxy] ${pathname}${legacyUrl.toString()}`);
    return NextResponse.rewrite(legacyUrl);
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/legacy/:path*"],
};

Part 3: Update Microfrontends Configuration

Update apps/marketing/microfrontends.json to include the proxy:

apps/marketing/microfrontends.json
{
  "$schema": "https://openapi.vercel.sh/microfrontends.json",
  "applications": {
    "@acme/marketing": {
      "development": {
        "fallback": "http://localhost:3000",
        "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/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 Vercel
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
  • You know how to create a proxy to a legacy application
  • You can plan a phased migration with feature flags
  • You know what metrics to monitor during migration

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.