Strangler Fig Pattern for Legacy CMS Migration
This guide applies the core pattern of Legacy System Decoupling Strategies. 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:
// 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.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Manifest store | edge config or KV, read per request | Flips and rollbacks without deploys. |
| Match rule | exact path or prefix followed by a slash | /blog must not match /blogroll. |
| Legacy proxy header | X-Proxy-By: strangler-edge |
Identifies proxied responses in logs. |
| Preview entry | ?preview only starts validation; token checked in the preview layer |
The query flag alone must never grant drafts. |
| Signature check | constant-time, equal-length buffers | timingSafeEqual throws on different lengths. |
Gotchas & Edge Cases
timingSafeEqualon unequal lengths. The ingestion route compares a header against the expected digest directly, which throws if a malformed signature has a different length. Compare lengths first and return 401.- Prefix matching. The manifest check uses
startsWith(${p}/), which is correct; a naivestartsWith(p)would also capture/blog-archive. Keep the slash. - Legacy absolute links. Legacy pages link to their own host. Rewrite links in proxied HTML, or readers bounce between the public domain and the legacy host.
- Admin paths. Excluding
/adminfrom routing assumes the legacy admin lives under the public domain. Move it to its own hostname during the migration to avoid proxy edge cases entirely.
Worked Example
A SaaS company moved its marketing site from Drupal to a headless stack over twenty weeks using this pattern. Careers went first, as a low-risk rehearsal of the whole process; the blog, the largest group, took four weeks including redirect work; product pages waited until conversion tracking was verified on the new stack. When a product page flip showed a drop in demo requests on day two, the team removed /products from the manifest, traffic returned to Drupal within a minute, and the cause, a missing form field, was fixed before flipping again the next day.
Frequently Asked Questions
Why not migrate the homepage first, since it matters most?
The homepage usually aggregates content from every section. Migrating it first means building integrations with content that has not moved yet. Migrating it last lets it draw entirely from the new system.
How does the strangler pattern interact with SEO?
URLs stay the same for proxied routes, and migrated routes keep their paths or redirect once. Search engines see a single site throughout, so equity is preserved as long as canonical tags and structured data match.
When is the strangler pattern the wrong choice?
For very small sites, where a single cutover weekend is simpler, or when the legacy platform cannot be proxied reliably, for example because it depends on a host-bound login flow for all pages.
How do we measure progress?
Chart the share of requests served by each origin, using the proxy header. When the legacy share reaches zero and stays there for a full editorial cycle, the strangler has done its work and the legacy stack can be retired.
Where should migration decisions be recorded?
In a short decision log next to the migration code: what moved when, what was archived instead of migrated, and why. Six months later, that log answers questions nobody remembers the reasons for, such as why a section redirects rather than exists.