---
title: "Incremental Migration"
description: "Apply the strangler fig pattern to migrate from legacy applications to microfrontends piece by piece."
canonical_url: "https://vercel.com/academy/microfrontends-on-vercel/incremental-migration"
md_url: "https://vercel.com/academy/microfrontends-on-vercel/incremental-migration.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-11-04T22:26:55.394Z"
content_type: "lesson"
course: "microfrontends-on-vercel"
course_title: "Microfrontends on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Incremental Migration

# 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 Vercel project that rewrites to it](https://vercel.com/docs/microfrontends/routing#route-to-externally-hosted-applications):

```typescript title="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 Vercel 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

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

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

### Phase 2: Carve Out First Route

```json
{
  "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`.

```json
"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:

```typescript title="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:

```typescript title="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:

```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
      }
    }
  }
}
```

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):

```bash
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:

```json title="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:

```bash
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:

```typescript title="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:

```json title="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:

```bash
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:

```bash
# Should proxy to httpbin
curl http://localhost:3024/legacy/get

# Should return JSON like:
{
  "args": {},
  "headers": { ... },
  "origin": "...",
  "url": "https://httpbin.org/get"
}
```

## Commit

```bash
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

| Phase           | Action                     | Validation                   |
| --------------- | -------------------------- | ---------------------------- |
| Setup           | Create proxy project       | Legacy accessible via Vercel |
| First route     | Carve out low-risk route   | Route serves from new app    |
| Feature flag    | Enable for internal users  | Both paths work              |
| Gradual rollout | Increase percentage        | Monitor error rates          |
| Full migration  | Remove flag, update config | 100% on new app              |
| Cleanup         | Remove proxy, decommission | Legacy 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

| Scenario                            | Good Fit?                |
| ----------------------------------- | ------------------------ |
| Legacy app on another CDN           | Yes                      |
| Monolith you want to split          | Yes                      |
| Framework migration (CRA → Next.js) | Yes                      |
| Small app rewrite                   | Probably overkill        |
| Tightly coupled legacy system       | Challenging 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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
