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 Vercel 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 → [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:
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
- 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": "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/microfrontendsPart 2: Configure the Proxy Middleware
Create 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:
{
"$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 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 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
- 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
| 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?