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:
// 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.
- Token Generation: When an editor clicks “Preview” in either CMS, generate a JWT containing
{ slug, status: 'draft', iat, exp }signed with a sharedPREVIEW_SECRET. - Cookie Propagation: Set the token as a
SameSite=Lax,Securecookie scoped to the domain. - 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.
- 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:
// 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):
- Fetch Canonical State: Query the headless CMS for all migrated slugs and the legacy CMS for the same set.
- Diff & Log: Compare
lastModified,status, and critical fields (title, body, metadata). Flag discrepancies. - 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.
- Audit Trail: Write all drift events to a structured log (JSON/CSV) with timestamps, source, and resolution status.
// 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:
- DNS & CDN Switch: Update origin rules to point 100% of traffic to the headless CDN. Purge all edge caches.
- Webhook Disable: Deactivate legacy CMS webhooks. Verify no orphaned build triggers remain in CI/CD.
- Preview Token Rotation: Invalidate the shared
PREVIEW_SECRETand issue a new one scoped exclusively to the headless preview API. - Legacy Read-Only Mode: Switch the legacy CMS to read-only to prevent accidental writes during the transition window.
- Archive & Decommission: Export legacy database, verify backups, and terminate legacy infrastructure.
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
lastModifiedvalues 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.