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:
- Wrap the legacy system - Put Edgenivo in front of everything
- Carve out routes - Move routes one at a time to new microfrontends
- Strangle the legacy - Eventually, no routes go to the old system
- 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:
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
- Create a proxy project that forwards to an external legacy URL
- Configure it as the default microfrontend
- 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:
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:
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:
{
"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-proxyPart 2: Rewrite /legacy to httpbin
Replace apps/legacy-proxy/next.config.ts. It needs withMicrofrontends, like every app in the group, plus two rewrites:
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:
{
"$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 devTest the proxy routes:
- Visit
http://localhost:3024/legacy/get- Should return JSON from httpbin - Visit
http://localhost:3024/legacy/headers- Shows request headers - 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:
- Legacy: Old Pages Router application serving everything
- Strategy: Carve out sections into new App Router apps
- Implementation: Feature flags for gradual rollout per route
- Monitoring: Watch for errors before removing legacy code
- Cleanup: Remove legacy routes once stable
Migration Checklist
| Phase | Action | Validation |
|---|---|---|
| Setup | Create proxy project | Legacy accessible via Edgenivo |
| 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-proxyis namedacme-legacy-proxy, runs on port 3004, and wraps its config withwithMicrofrontendslocalhost:3024/legacy/getreturns JSON from httpbin, andlocalhost:3024/still serves marketing- You can plan a phased migration with a
flagon 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.
Was this helpful?