Handling 404s and redirects in headless routing

A headless CMS API returns 200 OK with a null payload for a route that doesn’t exist — the soft 404 — and a client-side router reads that as success, rendering an empty layout that crawlers index as a real page. This guide makes routing a deterministic data contract: enforce real status codes at the server boundary, flatten redirect chains at the edge, and wire deletions to cache and sitemap purges. Loose routing fragments link equity and crawl budget.

The Soft 404 Problem

The dominant failure mode is the soft 404. GraphQL and REST endpoints return 200 OK with a null payload or empty data array for a missing route; the client router treats it as successful hydration and renders an empty layout or skeleton instead of erroring. Fix it by intercepting the fetch at the server boundary and emitting a real status code before rendering.

The server-boundary contract maps each payload shape to an explicit status code before any HTML renders.

flowchart TD
  Req["Request /path"] --> Fetch["Fetch CMS route at server boundary"]
  Fetch --> Null{"Payload null / empty?"}
  Null -->|"yes"| NF["notFound() -> real 404"]
  Null -->|"no"| Red{"Has redirect?"}
  Red -->|"permanent"| P301["permanentRedirect -> 301"]
  Red -->|"temporary"| P302["redirect -> 302"]
  Red -->|"no"| Render["Render content (200)"]
TSX
// src/app/[...slug]/page.tsx
import { notFound, redirect, permanentRedirect } from 'next/navigation';
import { fetchCMSRoute } from '@/lib/cms';
import type { Metadata } from 'next';

interface CMSRouteResponse {
  id: string;
  title: string;
  content: Record<string, unknown>;
  redirect?: { target: string; type: 'permanent' | 'temporary' };
}

export async function generateMetadata({ params }: { params: { slug: string[] } }): Promise<Metadata> {
  const path = params.slug.join('/');
  const data = await fetchCMSRoute(path);

  if (!data || data.redirect) {
    return { robots: 'noindex' };
  }
  return { title: data.title };
}

export default async function Page({ params }: { params: { slug: string[] } }) {
  const path = params.slug.join('/');
  const response = await fetchCMSRoute(path);

  if (!response) {
    notFound();
  }

  if (response.redirect) {
    if (response.redirect.type === 'permanent') {
      permanentRedirect(response.redirect.target);
    } else {
      redirect(response.redirect.target);
    }
  }

  return <ContentRenderer data={response} />;
}

This fetch-then-validate contract checks the payload before hydration, so the framework emits a real 404 or 301/302 that crawlers and analytics can trust.

Flattening Redirect Chains at the Edge

Redirect chains compound latency and bleed link equity. Legacy mappings in a flat key-value table often require several hops to reach the destination. Flatten them at build time or ISR revalidation — cache the resolved target alongside the source for single-hop resolution. For high-traffic legacy paths, skip the Node.js runtime entirely: declarative redirect arrays evaluated at the CDN layer eliminate cold-start latency and serverless cost.

JSON
{
  "redirects": [
    { "source": "/legacy-blog/:slug", "destination": "/blog/:slug", "statusCode": 301 },
    { "source": "/en/old-product", "destination": "/en/new-product", "statusCode": 302 }
  ]
}

The provider matches the source pattern against the incoming URI, captures the group, and issues the status before the request reaches the application server.

Multilingual Routing & Canonical Fallbacks

Missing localized slugs should cascade to a default locale, not hard-404. A deterministic resolver queries the CMS locale matrix before rendering, then returns a 302 to the canonical locale or serves default content with explicit hreflang. When fallback routes lack canonicalization, search engines flag duplicate content — inject rel="canonical" pointing to the authoritative locale during metadata generation. This feeds directly into Localization & SEO Optimization.

TypeScript
// Locale fallback resolver
export async function resolveLocaleFallback(path: string, requestedLocale: string): Promise<{ url: string; status: 302 | 200 }> {
  const availableLocales = await fetchAvailableLocales(path);
  
  if (availableLocales.includes(requestedLocale)) {
    return { url: `/${requestedLocale}/${path}`, status: 200 };
  }
  
  const defaultLocale = 'en';
  return { url: `/${defaultLocale}/${path}`, status: 302 };
}

Cache Invalidation & Webhook Purging

ISR and SSG invalidation races generate phantom 404s: a deletion leaves the cached route live while the framework fetches a now-missing payload. Purge via webhook, targeting both the page route and its parent index, and keep routing state synced with Dynamic Sitemap Generation so the sitemap reflects real availability. On a deletion event, purge the route cache and the sitemap index in parallel.

TypeScript
// Webhook handler: purge stale routes
export async function handleCMSWebhook(payload: { event: string; slug: string }) {
  if (payload.event === 'entry.delete') {
    const routePath = `/blog/${payload.slug}`;
    
    // Purge ISR cache for the specific route
    await revalidatePath(routePath);
    
    // Purge parent index to prevent stale aggregation
    await revalidatePath('/blog');
    
    // Trigger sitemap regeneration
    await regenerateSitemap();
  }
}

Standards Compliance

Validate payload schemas at the fetch layer, map status codes explicitly, and push high-volume redirects to the CDN edge. HTTP Semantics RFC 9110 defines the status codes; the Next.js Routing Documentation covers framework specifics. Explicit state management, edge interception, and deterministic invalidation are what eliminate soft 404s, preserve link equity, and keep crawl behavior predictable.