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 guide belongs to Dynamic Sitemap Generation, because status codes and the sitemap must always agree about which URLs exist.

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.

Mapping payloads to status codesThe server fetches the CMS route; a null or empty payload returns a real 404; a payload with a permanent redirect returns 301 and a temporary one 302; otherwise the content renders with 200.Request/pathFetch atserver boundaryPayloadempty?404notFound()Redirectset?301permanent302temporary200renderyesnopermanenttemporarynone
The status is decided before any HTML is rendered.
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

A missing localized page is a routing decision, not automatically a 404. For content types that fall back, serve the fallback content on the localized URL with a notice and a canonical to the source page, as described in Content Fallback & Routing; for types that must not fall back, return a real 404. A temporary redirect to the default locale is a third option, useful for pages that will never be translated, but it takes readers out of their locale’s navigation. Whichever you choose per type, decide it in one resolver so status codes, canonicals and sitemaps stay consistent. 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 };
  }
  
  // Only for content types configured to redirect instead of falling back.
  const defaultLocale = 'en';
  return { url: `/${defaultLocale}/${path}`, status: 302 };
}

Deleted Content: 404 or 410

When content is deleted on purpose, 410 Gone tells search engines the removal is permanent, and they drop the URL faster than after a 404. Keep a small table of deleted slugs, written by the delete webhook, and return 410 for them; everything else that does not exist returns 404. When deleted content has a natural successor, such as a replaced product, create a redirect to the successor instead. Editors should make that choice at deletion time, so give the CMS a “replaced by” field on routable types; the webhook turns it into a redirect entry automatically.

Which status for which situationThe HTTP status to return for a never-existing URL, deliberately deleted content, content moved to a new URL, temporarily unavailable content, and untranslated pages in locales configured without fallback.SituationStatusEffect on searchNever existed, typo404dropped after a whileDeleted on purpose410dropped quicklyMoved or replaced301 to successorsignals transferredTemporarily unavailable503 with Retry-Afterkept, retried laterNo translation, no fallback404 in that localelocale URL not indexed
Each status tells crawlers something different; choose deliberately.

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.

A Useful 404 Page

A real 404 status is for crawlers; the page itself is for readers. Render the not-found page in the requested locale, with the site’s navigation, a search box pre-filled with words from the requested path, and links to the most relevant sections. For paths that look like old URL patterns, suggest the likely new location. Log every 404 with the path and referrer, and review the most frequent ones weekly: internal referrers point at broken links to fix, external referrers at redirects worth adding. This small loop recovers a surprising share of lost traffic after migrations and restructurings, and it gives editors a concrete list of links to repair.

Gotchas & Edge Cases

  • Client-side navigation to missing pages. In single-page navigation, a missing route renders the not-found component without a status code. Crawlers always make full requests, so the server path must be correct; the client path only needs to render the same page.
  • Redirect loops. CMS-managed redirects can form loops when editors redirect A to B and later B to A. Detect cycles when flattening and refuse to publish them.
  • Case and trailing slashes. Normalize paths before matching redirects, or /Blog/Post/ and /blog/post need separate entries.
  • Metadata for 404 pages. Missing pages should not emit canonical tags or be listed anywhere. Keep the not-found page noindex.

Worked Example

A retailer’s product catalogue rotated seasonally, and discontinued products left thousands of soft 404s: the product route rendered an empty template with status 200. Search console reported them as soft 404s, and crawl stats showed crawlers spending a third of their requests on dead products. The team enforced notFound() at the server boundary, added a “replaced by” field that created 301s to successor products, returned 410 for discontinued products without successors, and flattened redirect chains at the edge nightly. Soft 404 reports dropped to zero within two months, and crawlers shifted their attention to live products.

Crawler requests by response typeShare of crawler requests hitting soft 404s, real 404 or 410 responses, redirects and live pages before and after enforcing status codes.Soft 404, before33 % of crawler requestsLive pages, before61 % of crawler requestsSoft 404, after0 % of crawler requestsLive pages, after88 % of crawler requests
Crawl budget moved from dead pages to live ones.

Rollout Checklist

  • Map empty payloads to notFound() at the server boundary.
  • Store redirects in the CMS and flatten chains before publishing them to the edge.
  • Return 410 for deliberately deleted content and 301 to successors.
  • Decide per content type whether missing translations fall back, redirect or 404.
  • Purge routes, listings and sitemaps when content is deleted.
  • Detect and reject redirect loops before they are published.

Frequently Asked Questions

Should missing pages redirect to the homepage?

No. Redirecting unrelated missing pages to the homepage is treated as a soft 404 and confuses readers. Return a helpful, localized 404 page with search and navigation instead.

Where should redirects live, in code or the CMS?

Structural redirects from migrations live in code or edge configuration; editorial redirects, such as renamed articles, live in the CMS and are exported to the edge.

How many redirects can the edge handle?

Thousands, with pattern rules for bulk moves. Very large maps belong in an edge key-value store looked up per request.

Do redirects need to be kept forever?

Keep permanent redirects for at least a year, and much longer for pages that still have inbound links from other sites. Removing them too early loses the signals they transfer.

Should 404 pages be cached?

Yes, briefly, for example for a minute, so bursts of requests for a missing URL do not reach the origin, but short enough that newly published content is not hidden behind a cached 404.