Parallel Running Legacy and Headless CMS During Migration

Within Legacy System Decoupling Strategies, parallel running is the riskiest phase. Running a legacy monolithic CMS and a headless stack in parallel produces routing collisions, dual sources of truth, and inconsistent preview states. The goal is to keep editorial operations running while you decouple content models incrementally — avoiding a hard cutover that risks data integrity or SEO rankings. That takes strict traffic partitioning, isolated draft environments, and a reconciliation layer that kills content drift before decommissioning.

Why Parallel Runs Fail

The root cause is almost always conflating authoring with delivery. Legacy systems couple relational storage to server-side rendering, generating HTML at request time. Headless stacks decouple storage from presentation and deliver via API with static/ISR rendering. When both serve overlapping URL spaces, the edge loses deterministic origin resolution.

The symptoms are predictable: cache stampedes from conflicting ETag headers, broken previews from mismatched auth scopes, and duplicate build pipelines firing at once. Webhooks from both systems often hit the same deployment endpoint, racing to overwrite each other’s state. Without a unified preview token, draft states diverge between the legacy UI and the headless preview. Isolating these concerns is core to the broader Preview & Draft Workflow Patterns and keeps phased migrations from tanking editorial velocity.

Deterministic Edge Routing

You need an edge proxy that selects the origin by path prefix, feature flag, and preview context, intercepting requests before they reach an origin so there’s no DNS-level fragmentation.

The middleware below splits traffic deterministically: legacy routing for unmigrated paths, the headless API for new content, and a secure cookie to isolate preview traffic. Cache headers are set explicitly to prevent ETag conflicts, per RFC 7232 conditional requests.

The edge resolves a single origin per request from path prefix and preview context:

One origin per request, with preview kept separateStatic assets and API routes pass through; other requests are routed by path prefix to the headless or legacy origin; requests carrying a valid preview cookie go to the same origin's draft rendering with no-store caching; every response carries an origin header.Request at edgeAsset / APIpass throughMigratedprefix?Headless originLegacy originPreview cookie?draft + no-storeyesno
Preview follows the same routing as public traffic, so editors preview migrated content in the new system and unmigrated content in the old one.
TypeScript
// middleware.ts (Next.js App Router / Edge Runtime)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const LEGACY_ORIGIN = process.env.LEGACY_CMS_URL;
const HEADLESS_ORIGIN = process.env.HEADLESS_CMS_API_URL;
const MIGRATION_PREFIXES = ['/blog', '/resources', '/case-studies'];
const PREVIEW_COOKIE = 'preview_token';
const PREVIEW_SECRET = process.env.PREVIEW_SECRET;

export function middleware(request: NextRequest) {
  const url = new URL(request.url);
  const { pathname } = url;
  const isPreview = request.cookies.has(PREVIEW_COOKIE);
  const isStaticAsset = /\.(js|css|png|jpg|jpeg|svg|ico|woff2?)$/i.test(pathname);

  // Bypass routing for static assets and API routes
  if (isStaticAsset || pathname.startsWith('/api/') || pathname.startsWith('/_next/')) {
    return NextResponse.next();
  }

  // Determine target origin
  const isMigratedPath = MIGRATION_PREFIXES.some(prefix => pathname.startsWith(prefix));
  // Preview follows the same routing as public traffic: migrated paths preview
  // in the headless system, everything else in the legacy system.
  const targetOrigin = isMigratedPath ? HEADLESS_ORIGIN : LEGACY_ORIGIN;

  // Construct rewritten URL
  const rewrittenUrl = new URL(pathname, targetOrigin);
  if (url.search) rewrittenUrl.search = url.search;

  // Build response with explicit cache control to prevent ETag collisions
  const response = NextResponse.rewrite(rewrittenUrl);
  response.headers.set('x-cms-origin', isMigratedPath ? 'headless' : 'legacy');
  response.headers.set('Cache-Control', isPreview ? 'private, no-cache, no-store, must-revalidate' : 'public, s-maxage=3600, stale-while-revalidate=86400');
  
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Legacy paths stay untouched while new content routes to the headless origin. The x-cms-origin header gives you immediate visibility when debugging cache behavior and CDN routing rules.

Unified Preview Token Strategy

Draft sync fails when preview auth is siloed per system. Use one cryptographically signed token that both the legacy preview endpoint and the headless preview API recognize.

  1. Token Generation: When an editor clicks “Preview” in either CMS, generate a JWT containing { slug, status: 'draft', iat, exp } signed with a shared PREVIEW_SECRET.
  2. Cookie Propagation: Set the token as a SameSite=Lax, Secure cookie scoped to the domain.
  3. Validation & Routing: The edge middleware intercepts requests with the cookie, bypasses cache, and routes to the appropriate preview endpoint. The legacy system validates the signature and renders the draft; the headless system fetches the draft via API and returns it via ISR/SSR.
  4. State Isolation: Never expose internal draft APIs publicly. The middleware acts as a gatekeeper, ensuring that only validated preview tokens can trigger draft rendering.

Editorial teams now see identical draft states regardless of which CMS UI they use. For token lifecycle and cross-system state validation, see Legacy System Decoupling Strategies.

Webhook Namespacing & Idempotent Rebuilds

Two CMSs means two publish pipelines. If both hit the same deployment webhook, you get build collisions, race conditions, and wasted CI/CD minutes.

Namespacing & Verification

Run a distinct endpoint per system:

  • /api/webhooks/legacy → legacy cache purge or static regeneration
  • /api/webhooks/headless → headless ISR rebuild or deployment pipeline

Verify HMAC signatures on both to reject spoofed requests. Validate X-Signature or X-Hub-Signature-256 before processing, following the GitHub webhook security guidelines.

Idempotent Ingestion & Queueing

Wrap rebuild triggers in an idempotency layer:

TypeScript
// Simplified idempotent webhook handler
import { createHash } from 'crypto';

export async function handleWebhook(req: Request) {
  const payload = await req.json();
  const idempotencyKey = createHash('sha256').update(JSON.stringify(payload)).digest('hex');

  // Check distributed cache (Redis/Memcached) for existing key
  const exists = await cache.get(`rebuild:${idempotencyKey}`);
  if (exists) return new Response('Already processing', { status: 200 });

  await cache.set(`rebuild:${idempotencyKey}`, 'true', { ttl: 300 });
  
  // Push to build queue (e.g., SQS, Cloudflare Queues, Vercel Cron)
  await buildQueue.enqueue({ cms: payload.source, contentId: payload.id });
  
  return new Response('Queued', { status: 202 });
}

This prevents duplicate rebuilds, gives exactly-once processing, and decouples webhook delivery from build execution.

Content Drift Reconciliation

Even with strict routing and webhook isolation, manual edits in the legacy UI or API sync lag will drift content out of sync. A scheduled reconciliation layer is mandatory before cutover.

Run a daily cron job (GitHub Actions, Vercel Cron, or AWS EventBridge):

  1. Fetch Canonical State: Query the headless CMS for all migrated slugs and the legacy CMS for the same set.
  2. Diff & Log: Compare lastModified, status, and critical fields (title, body, metadata). Flag discrepancies.
  3. Auto-Sync or Alert: For low-risk fields, auto-push legacy changes to headless via API. For high-risk fields (SEO metadata, published status), route alerts to a Slack/Teams channel for manual review.
  4. Audit Trail: Write all drift events to a structured log (JSON/CSV) with timestamps, source, and resolution status.
Drift categories and how to resolve themTypes of content drift found by the reconciliation job, whether they are resolved automatically, and who reviews them.DriftAuto-resolve?ReviewerBody text or images changed in legacypush to headlessnoneStatus differs (published vs draft)nocontent leadsearch title, description, canonicalnoSEO leadItem missing in headlesscreate as drafteditor of the sectionItem missing in legacyflagmigration owner
Only low-risk fields sync automatically; anything that changes visibility or search signals goes to a person.
TypeScript
// reconciliation.ts (Node.js / TypeScript)
async function reconcileDrift() {
  const legacyData = await fetchLegacyContent();
  const headlessData = await fetchHeadlessContent();
  
  const drift = legacyData.filter(l => {
    const h = headlessData.find(x => x.slug === l.slug);
    return !h || h.lastModified < l.lastModified || h.status !== l.status;
  });

  if (drift.length > 0) {
    console.warn(`[DRIFT] ${drift.length} items out of sync. Initiating resolution...`);
    await pushToHeadless(drift);
    await logAudit(drift);
  }
}

Run this continuously and the headless system becomes the authoritative source before DNS cutover, closing post-migration content gaps.

Final Cutover Protocol

Once reconciliation logs show zero drift for 72 consecutive hours, decommission:

  1. DNS & CDN Switch: Update origin rules to point 100% of traffic to the headless CDN. Purge all edge caches.
  2. Webhook Disable: Deactivate legacy CMS webhooks. Verify no orphaned build triggers remain in CI/CD.
  3. Preview Token Rotation: Invalidate the shared PREVIEW_SECRET and issue a new one scoped exclusively to the headless preview API.
  4. Legacy Read-Only Mode: Switch the legacy CMS to read-only to prevent accidental writes during the transition window.
  5. Archive & Decommission: Export legacy database, verify backups, and terminate legacy infrastructure.
Cutover protocol timelineAfter 72 hours of zero drift, traffic is switched and caches purged, legacy webhooks are disabled, preview secrets rotated, the legacy CMS set to read-only, and infrastructure archived two weeks later.Zero-drift observation72 hSwitch + purgeWebhooks off, secrets rotatedLegacy read-onlyreference onlyArchive + decommission0 days5 days10 days15 days
Read-only mode keeps the legacy CMS available for reference without allowing edits that would drift again.

Parallel execution is a controlled decoupling exercise, not a steady state. Deterministic routing, a unified preview token, namespaced webhooks, and continuous drift reconciliation let you migrate without disrupting editorial workflows or site performance.

Configuration Reference

Setting Value Why
Migration prefixes runtime config, not code Moving a section is a config change with instant rollback.
Origin header x-cms-origin on every response Debugging and per-origin monitoring.
Webhook endpoints one per system, each with its own secret No cross-system rebuild races.
Reconciliation daily, plus on demand before each flip Drift is found before readers see it.
Cutover gate 72 h of zero drift Evidence that sync is complete and stable.

Gotchas & Edge Cases

  • Preview routed to the wrong system. An earlier version of the middleware sent every preview request to the legacy origin, so editors of migrated sections previewed stale legacy content. Route preview by the same prefixes as public traffic.
  • Hashing the whole webhook body. The idempotency key in the handler hashes the full payload, which includes timestamps in many CMSs, so retries of the same event hash differently. Key on entry id, event and revision instead.
  • Drift from time zones. Comparing lastModified values from two systems in different time zones produces false drift. Normalize to UTC before comparing.
  • Search engines seeing both. If both origins are reachable on separate hostnames, search engines may index duplicates. Serve only the public domain, and block the legacy hostname with authentication or noindex.

Frequently Asked Questions

How long should parallel running last?

As short as possible and as long as necessary: long enough for every route group to pass its comparison checks and a full editorial cycle, which is usually weeks to a few months. Parallel running costs double operational effort, so plan its end from the start.

Can editors edit in both systems during parallel running?

Not for the same content type. Assign each content type to one system of record for the whole period, and sync one way only.

What is the best evidence that we are ready to cut over?

Zero drift for several days, redirect coverage for all legacy URLs with traffic, and a successful rehearsal of the cutover steps in staging, including rollback.