Parallel running legacy and headless CMS during migration
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:
flowchart TD
A["Request at edge proxy"] --> B{"Static asset or /api?"}
B -->|yes| C["Pass through"]
B -->|no| D{"Preview cookie set?"}
D -->|yes| E["Legacy origin, no-store cache"]
D -->|no| F{"Path in migration prefixes?"}
F -->|yes| G["Headless origin, s-maxage cache"]
F -->|no| H["Legacy origin, s-maxage cache"]
G --> I["Set x-cms-origin header"]
H --> I
E --> I
// 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));
const targetOrigin = isMigratedPath && !isPreview ? 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.