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:

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Route groups moving through the manifestRoute groups are added to the migrated manifest one after another, from low-risk sections to the homepage, each followed by an observation window, until the legacy manifest is empty./careersflip + observe/resources/bloglargest group/case-studies/productsrevenue critical/ (homepage)last0 weeks5 weeks10 weeks15 weeks20 weeks
Each group gets its own flip and observation window; the homepage goes last because it depends on everything else.

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:

The manifest-driven routing decisionAssets, API and admin paths pass through; draft requests are rewritten to an isolated preview layer that validates the token; paths in the migrated manifest render with the headless frontend, and everything else is proxied to the legacy origin.RequestAsset, /api,/admin?Pass throughDraftsignal?Preview layervalidates tokenIn migratedmanifest?Headless renderProxy to legacyyesnoyesnoyesno
The manifest is the only thing that changes as the migration progresses; the logic stays the same until the last route moves.
TypeScript
// 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.

TypeScript
// 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:

TypeScript
// 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:

Strangler failure modes and their signalsCommon failure modes of strangler routing, the signal that reveals each one and the fix.FailureSignalFixRouting loop508 or timeouts on one pathexclude path from matcherSession leakageusers logged out after proxyforward Host, fix cookie scopeStale ISR after syncold content with HITmatch revalidate path or tagBroken legacy media404s under /wp-contentmap media paths to CDNUnauthorized previewpreview reachable via ?previewvalidate token in preview layer
Every failure here shows up in a header or a log line within minutes of a flip, if you look.
  • Routing loops. Don’t let the middleware proxy /api/cms-sync or 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 Host header and use SameSite=None; Secure for cross-origin preview sessions.
  • ISR stale state. If updates don’t appear after a webhook, confirm the revalidate tag matches the route pattern and the CDN honors Cache-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

  • timingSafeEqual on 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 naive startsWith(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 /admin from 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.