Fallback Rendering Strategies During Legacy Decommission
As part of Legacy System Decoupling Strategies, this guide covers the transition period. During a phased migration, the frontend has to serve modern headless routes and unmigrated legacy pages from the same domain without route collisions, cache poisoning, or broken previews. The fix is deterministic fallback: intercept any route the headless CMS can’t resolve, forward it to the legacy origin with preview tokens intact, and isolate the two response types in the cache so neither bleeds into the other. Skip this and you get 404 cascades, stale ISR entries, and legacy markup served on modern URLs.
Where Fallback Routing Breaks
Three mismatches cause most failures:
- Route resolution gaps. Headless content models rarely mirror legacy slugs. An unmigrated path falls through to static generation, which returns empty props or a stale cache entry from a prior deploy.
- Preview token stripping. Draft workflows attach auth tokens to headless API calls but drop them on the fallback hop, so unpublished legacy content returns
401/403. - Cache-key collisions. Headless JSON and legacy HTML share a cache key when
Varyis misconfigured — common when the edge normalizes query params or ignores custom headers during key generation. Modern routes then serve legacy markup and vice versa.
Implementation
1. Edge route interception
Check route existence against the CMS before delegating to the legacy origin. Use a precomputed route registry rather than runtime regex to keep the edge decision under ~10ms.
The edge decides each request’s origin before any rendering happens:
// middleware.ts (Next.js / Edge Runtime)
import { NextRequest, NextResponse } from 'next/server';
import { fetchHeadlessRoute } from './lib/cms-client';
const LEGACY_BASE_URL = process.env.LEGACY_CMS_ORIGIN;
const FALLBACK_CACHE_TTL = 300; // 5 minutes for transitional content
export async function middleware(req: NextRequest) {
const { pathname, searchParams } = req.nextUrl;
const previewToken = searchParams.get('preview_token');
const isPreview = !!previewToken;
// Bypass static assets, API routes, and known headless paths
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/') ||
pathname.match(/\.(png|jpg|svg|css|js|woff2)$/i)
) {
return NextResponse.next();
}
// Fast existence check against headless CMS
const routeExists = await fetchHeadlessRoute(pathname, { preview: isPreview });
if (routeExists) {
return NextResponse.next(); // Proceed to headless SSR/ISR
}
// Construct legacy fallback URL with token passthrough
const legacyUrl = new URL(pathname, LEGACY_BASE_URL);
if (previewToken) {
legacyUrl.searchParams.set('preview_token', previewToken);
}
const legacyRes = await fetch(legacyUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html',
'X-Preview-Mode': isPreview ? 'true' : 'false',
'Cache-Control': `public, max-age=${FALLBACK_CACHE_TTL}, stale-while-revalidate=60`,
'X-Forwarded-Host': req.headers.get('host') || '',
},
next: { revalidate: FALLBACK_CACHE_TTL }
});
// Isolate cache keys to prevent poisoning
const responseHeaders = new Headers(legacyRes.headers);
responseHeaders.set('Vary', 'Accept, X-Preview-Mode');
responseHeaders.set('X-Legacy-Fallback', 'true');
responseHeaders.set('Cache-Control', `public, max-age=${FALLBACK_CACHE_TTL}, must-revalidate`);
return new NextResponse(legacyRes.body, {
status: legacyRes.status,
headers: responseHeaders,
});
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
2. Token passthrough for legacy auth
Legacy systems gate draft visibility behind session cookies, JWT query params, or custom headers. The proxy must forward these without logging them client-side or breaking CORS. Strip Set-Cookie from legacy responses and move it into the modern session store if you need it. Token lifecycle and draft propagation follow the same rules as the rest of your Token-Based Preview Authentication — reuse that logic rather than inventing a parallel path at the edge.
3. Cache-key segregation
Set Vary so the cache differentiates legacy from headless responses by Accept and your preview flag. The MDN reference on the Vary header documents the normalization rules. Add a deterministic cache prefix to legacy fallbacks:
Cache-Control: public, max-age=300, stale-while-revalidate=60
Vary: Accept, X-Preview-Mode, X-Legacy-Fallback
X-Cache-Key: legacy-fallback-{pathname}-{preview-mode}
On Vercel, Cloudflare, or Fastly, configure cache rules to ignore the preview_token query param during key generation so identical draft URLs don’t fragment the cache.
4. Component-level markup hydration
Avoid full-page redirects. Fetch the legacy HTML server-side, parse the content nodes, and inject them into a React/Vue wrapper. Sanitize with DOMPurify and isolate legacy CSS via Shadow DOM or scoped class prefixes to stop style collisions. This keeps routing consistent while you replace components one at a time. For the broader sequencing, see Legacy System Decoupling Strategies.
// components/LegacyFallback.tsx
import { parseHTML } from '@/lib/html-parser';
import DOMPurify from 'isomorphic-dompurify';
export async function LegacyFallback({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html, { ADD_ATTR: ['data-legacy-id'] });
const { title, mainContent, meta } = parseHTML(sanitized);
return (
<article className="legacy-wrapper" data-origin="legacy-cms">
<h1 className="legacy-title">{title}</h1>
<div className="legacy-content" dangerouslySetInnerHTML={{ __html: mainContent }} />
<meta name="legacy-meta" content={JSON.stringify(meta)} />
</article>
);
}
Validation & Telemetry
Fallback routing degrades silently, so instrument it:
- Route-hit logging. Emit structured logs with
pathname,origin(headless|legacy),cache_status, andpreview_mode. Filter onX-Legacy-Fallback: trueto track what’s still unmigrated. - 404 ratio. Alert when legacy fallback
404rate exceeds 5% over 15 minutes — that signals broken legacy redirects or a decommissioned endpoint. - Preview propagation. Run Playwright/Cypress checks that
?preview_token=xyzrenders draft content from both origins without a401. - Cache-hit audits. Watch CDN dashboards for
Varycompliance. A sudden drop on legacy routes usually means a header misconfig or query-param normalization conflict.
Deterministic routing, strict cache isolation, and token passthrough let you decommission the legacy stack without dropping content or leaking SEO equity mid-migration.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Route registry | in-memory set loaded from edge config | Microsecond lookups instead of a CMS call per request. |
| Legacy TTL | 5 min | Legacy content still changes during the transition. |
| Fallback marker | X-Legacy-Fallback: true |
Measure and purge fallback traffic separately. |
| Preview forwarding | server-side session, not query tokens | Keeps credentials out of URLs and logs. |
Legacy Set-Cookie |
stripped | Legacy sessions must not leak into the new domain’s cookies. |
Gotchas & Edge Cases
- A CMS call per request. The middleware example calls
fetchHeadlessRoutefor every page request, which adds latency and CMS load. Replace it with a registry of migrated paths, published to edge config whenever routes move. - Preview tokens in query strings. Forwarding
preview_tokenas a query parameter to the legacy origin puts it in legacy access logs. Exchange it for a server-side session at the edge, and forward preview state to the legacy origin through a header the legacy stack trusts only from the proxy’s IP. - Relative URLs in legacy HTML. Legacy markup served through the new domain may reference assets with paths that only resolve on the legacy host. Rewrite asset URLs in the fallback response or proxy the legacy asset paths as well.
- Double headers and footers. Wrapping legacy HTML inside the new layout duplicates navigation. Extract only the main content, as the component example does, or serve the legacy page unwrapped until it migrates.
- SEO signals. Legacy pages served through the new domain must keep their canonical tags pointing at the public URL, not the legacy host, or search engines may index the wrong domain.
Worked Example
A publisher migrating 40,000 pages route group by route group put the fallback at the edge on day one. For the first weeks, 85 percent of traffic still reached the legacy origin through it; as sections moved, the fallback’s share fell week by week on the dashboard, which became the migration’s progress report for management. When a newly migrated section turned out to be missing 200 older articles, the registry simply did not include their paths, so they kept rendering from legacy while the team fixed the extraction. Readers never saw a 404, and the legacy origin was switched off in week eleven, after the fallback share had stayed at zero for a week.
Rollout Checklist
- Publish a registry of migrated paths to edge config and update it with every route group flip.
- Proxy unmigrated routes to the legacy origin under the public domain, with a fallback marker header.
- Give legacy responses their own cache TTL and purge tag, and strip legacy cookies.
- Forward preview state server-side, never as a query token.
- Chart the fallback’s traffic share and switch the legacy origin off only after it has stayed at zero.
Frequently Asked Questions
Should the fallback proxy or redirect to the legacy host?
Proxy, during the migration, so readers and search engines see one domain throughout. Redirecting to a legacy host splits link equity and confuses users when they bounce between hosts.
How do we know when it is safe to switch off the legacy origin?
When the fallback marker shows no traffic for a full editorial cycle, typically one to two weeks, and the redirect map covers every legacy URL that received traffic or links in the last year.
Can the fallback be used after the migration for archived content?
It can, but it keeps the legacy stack alive indefinitely. Prefer exporting archived pages to static HTML served from object storage, which retires the legacy application entirely.
Does the fallback affect Core Web Vitals?
Proxied legacy pages keep their legacy performance, plus a small proxy hop. Track vitals per origin using the fallback marker, so the migration’s improvements are visible and legacy pages do not drag down the new stack’s reported numbers unnoticed.
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.