Strangler Fig Pattern for Legacy CMS Migration
The Strangler fig pattern migrates a legacy CMS by intercepting routing at the edge, replacing server-rendered monolithic templates with a headless frontend one route at a time, with zero downtime. You decouple rendering from the legacy stack without breaking editorial workflows, SEO equity, or existing URLs. It hinges on deterministic path routing, synchronized content ingestion, and strict draft isolation.
Why Direct Cutovers Fail
Legacy platforms (WordPress, Drupal, Sitecore, AEM) tightly couple content storage, route resolution, and template rendering. Hard cutovers fail on three compounding mismatches:
- State divergence. Legacy relational databases and headless content graphs use different schemas, versioning models, and relationship paradigms. Without a deterministic sync layer, content drifts during migration, breaking referential integrity and producing 404s on nested routes.
- Preview fragmentation. Editors depend on authenticated draft previews that bypass production caches. Migrate routing without preserving token-based preview auth or isolating staging, and live editing breaks — teams end up publishing unreviewed content just to check a layout.
- Invalidation gaps. Legacy systems do page-level cache busting or manual purges; headless stacks use ISR, edge caching, or webhook rebuilds. Mismatched triggers leave content stale or overload the origin when legacy webhooks don’t map to modern revalidation endpoints.
Resolution Steps
- Edge routing interception. A reverse proxy or edge middleware evaluates each request against a dynamic path manifest: new routes resolve to the headless frontend, legacy routes proxy to the original CMS. No DNS or legacy server changes required.
- Content synchronization. Have the legacy CMS emit structured payloads via webhooks. A headless ingestion pipeline normalizes fields, resolves relative media URLs to absolute CDN paths, and publishes to the new content API or KV store.
- Draft isolation. Separate production and preview delivery. Draft requests carry a signed token that routes to a staging CDN layer or bypasses ISR, so editors see unreviewed changes without polluting production caches — see Preview & Draft Workflow Patterns.
- Incremental replacement. Start with low-risk, high-traffic routes (
/blog,/resources,/careers). Validate accessibility and Core Web Vitals before expanding rules. Update the manifest iteratively and watch error rates. - Decommission routes. Once a path is migrated, validated, and cache-stable, remove it from the manifest. Redirect legacy admin endpoints to the new dashboard and archive the old template engine.
Implementation
1. Edge Routing Middleware (Next.js App Router)
The middleware is the strangler’s root system: it matches the request path against a manifest, handles draft isolation, and proxies unmatched routes to the legacy origin. The edge runtime keeps routing decisions under ~10ms.
Each request runs the same manifest-driven decision before any rendering:
flowchart TD
A["Request"] --> B{"Asset, /api, /admin, has dot?"}
B -->|yes| C["NextResponse.next()"]
B -->|no| D{"Draft token or preview param?"}
D -->|yes| E["Rewrite to isolated /preview layer"]
D -->|no| F{"Path in migrated manifest?"}
F -->|yes| G["Render with headless frontend"]
F -->|no| H["Proxy to legacy origin"]
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// In production, fetch from Edge Config, Redis, or a KV store
const MIGRATED_PATHS = new Set(['/blog', '/resources', '/case-studies']);
const LEGACY_ORIGIN = process.env.LEGACY_CMS_URL || 'https://legacy-cms.internal';
export function middleware(req: NextRequest) {
const { pathname, searchParams } = req.nextUrl;
// Bypass static assets, Next.js internals, and admin panels
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.startsWith('/admin') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// Draft isolation: intercept preview tokens
const isDraft = searchParams.has('preview') || req.cookies.has('draft_token');
if (isDraft) {
// Route to isolated preview layer, bypassing ISR cache
const previewUrl = new URL(`/preview${pathname}`, req.url);
previewUrl.search = searchParams.toString();
return NextResponse.rewrite(previewUrl);
}
// Strangler routing logic
const isMigrated = MIGRATED_PATHS.has(pathname) ||
Array.from(MIGRATED_PATHS).some(p => pathname.startsWith(`${p}/`));
if (isMigrated) {
// Let Next.js handle rendering
return NextResponse.next();
}
// Proxy to legacy CMS
const legacyUrl = new URL(pathname, LEGACY_ORIGIN);
legacyUrl.search = searchParams.toString();
// Preserve original host for legacy cookie/session handling
const res = NextResponse.rewrite(legacyUrl);
res.headers.set('X-Proxy-By', 'strangler-edge');
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt).*)'],
};
2. Webhook Ingestion & Payload Normalization
Legacy webhooks rarely match the headless schema. The ingestion route verifies the signature, normalizes types, resolves media URLs, and triggers targeted revalidation.
// app/api/cms-sync/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
export async function POST(req: NextRequest) {
const signature = req.headers.get('x-cms-signature');
const rawBody = await req.text();
// HMAC verification to prevent unauthorized sync triggers
const expected = createHmac('sha256', process.env.CMS_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
if (!signature || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
// Normalize legacy payload to headless schema
const normalized = {
id: payload.post_id,
slug: payload.slug,
title: payload.title,
content: transformLegacyMarkup(payload.body),
publishedAt: payload.publish_date,
mediaUrls: payload.images?.map(img => resolveAbsoluteUrl(img.src)) || [],
};
// Persist to headless store (e.g., PostgreSQL, Sanity, Contentful)
await syncToContentStore(normalized);
// Trigger targeted ISR revalidation for the specific route
await fetch(new URL(`/api/revalidate?path=/${normalized.slug}`, req.url), {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.REVALIDATE_SECRET}` }
});
return NextResponse.json({ status: 'synced', slug: normalized.slug });
}
3. Draft Token Validation & Cache Bypass
Draft routing needs strict isolation: editors never see cached production content, production users never reach draft payloads. Gate it with a token check at the data-fetching layer:
// lib/fetch-content.ts
import { cookies } from 'next/headers';
export async function fetchContent(slug: string) {
const cookieStore = await cookies();
const draftToken = cookieStore.get('draft_token');
const isPreview = draftToken?.value === process.env.DRAFT_SECRET;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (isPreview) {
headers['X-Preview-Mode'] = 'true';
headers['Cache-Control'] = 'no-store, max-age=0';
}
const res = await fetch(`${process.env.HEADLESS_API}/content/${slug}`, {
headers,
next: isPreview ? { revalidate: 0 } : { revalidate: 3600 },
});
if (!res.ok) throw new Error(`Content fetch failed: ${res.status}`);
return res.json();
}
Validation & Debugging
Watch these failure modes on deploy:
- Routing loops. Don’t let the middleware proxy
/api/cms-syncor static assets back to the legacy origin — exclude them in the matcher. - Cookie/session leakage. Legacy platforms rely on domain-bound session cookies. When proxying, forward the
Hostheader and useSameSite=None; Securefor cross-origin preview sessions. - ISR stale state. If updates don’t appear after a webhook, confirm the
revalidatetag matches the route pattern and the CDN honorsCache-Control: s-maxage. - Media URL rewriting. Legacy relative paths (
/wp-content/uploads/...) break in headless deployments. Map legacy media directories to your CDN with a deterministic resolver during ingestion.
Plan this against the broader Legacy System Decoupling Strategies. Strict path manifests, cryptographic webhook verification, and isolated draft routing let you migrate a monolithic CMS to headless with zero downtime and predictable rollback.