Fallback Rendering Strategies During Legacy Decommission

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:

  1. 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.
  2. 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.
  3. Cache-key collisions. Headless JSON and legacy HTML share a cache key when Vary is 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:

flowchart TD
  A["Incoming request"] --> B{"Static asset or /api?"}
  B -->|yes| C["Pass through to Next.js"]
  B -->|no| D{"Headless route exists?"}
  D -->|yes| E["Headless SSR / ISR"]
  D -->|no| F["Fetch from legacy origin"]
  F --> G["Forward preview_token"]
  G --> H["Set Vary + X-Legacy-Fallback"]
  H --> I["Return isolated legacy response"]
TypeScript
// 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:

HTTP
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.

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

  1. Route-hit logging. Emit structured logs with pathname, origin (headless | legacy), cache_status, and preview_mode. Filter on X-Legacy-Fallback: true to track what’s still unmigrated.
  2. 404 ratio. Alert when legacy fallback 404 rate exceeds 5% over 15 minutes — that signals broken legacy redirects or a decommissioned endpoint.
  3. Preview propagation. Run Playwright/Cypress checks that ?preview_token=xyz renders draft content from both origins without a 401.
  4. Cache-hit audits. Watch CDN dashboards for Vary compliance. 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.