How to Fix: Wrong cache-control headers for 404 pages in the app router

7 min read

Next.js App Router 404 Pages Sending the Wrong Cache-Control Header: Cause, Fix, and Safe Workarounds

A missing route that returns a 404 should not be cached like a successful page, yet in this App Router case, a not-found response can inherit caching behavior that belongs to a normal 200 response path. The result is dangerous: CDNs, browsers, or reverse proxies may store a response that should stay dynamic or short-lived, making debugging painful and causing stale not-found behavior in production.

The issue is reproducible from the linked repository: reproduction project. After building and starting the app, opening the example route shows that the generated 404 page uses an unexpected Cache-Control header.

What the bug looks like

In the Next.js App Router, a route may render a valid page under some conditions and call notFound() under others. When that happens, the final HTTP status is correctly set to 404, but the response headers can still reflect caching rules that were computed for the original route segment. In practice, this means the framework may emit a cache policy more appropriate for a static or cacheable page than for a not-found response.

This is most visible when:

  • a route is otherwise statically optimized,
  • the segment exports revalidate,
  • data fetching is cached, or
  • the route transitions into notFound() late in the render pipeline.

If you put a CDN or proxy in front of the app, that incorrect header can cause a non-existent path to be cached for longer than expected.

Understanding the Root Cause

The root issue comes from how App Router rendering combines route-level caching decisions with runtime control flow.

In Next.js, caching is often determined from signals such as:

  • static rendering eligibility,
  • segment config like export const revalidate,
  • fetch cache behavior,
  • dynamic functions such as headers() or cookies(),
  • and whether the route can be pre-rendered.

When a page later throws notFound(), the framework converts rendering into a 404 response. However, if caching metadata was already derived from the segment before the not-found branch was reached, the final headers may not be recalculated strictly for the 404 case. In other words, the status code changes, but the associated Cache-Control policy can remain tied to the original route cache mode.

That is why this bug appears inconsistent at first glance. The route is not broken in the traditional sense; instead, the response assembly pipeline mixes two different concerns:

  1. how to render and cache the route segment,
  2. how to finalize the response after notFound() is thrown.

If those concerns are not synchronized, the response ends up as a 404 with 200-like cache semantics.

Step-by-Step Solution

Until the framework behavior is corrected upstream, the safest fix is to make routes that can intentionally end in notFound() behave as dynamic or explicitly no-store when a stale 404 would be harmful.

The exact workaround depends on your route design.

1. Identify routes that may call notFound()

Typical examples include dynamic product pages, CMS-backed slugs, or resource lookups where the data may disappear between builds and requests.

import { notFound } from 'next/navigation'

export default async function Page({ params }) {
  const item = await getItem(params.slug)

  if (!item) {
    notFound()
  }

  return <div>{item.title}</div>
}

If this route is static or revalidated, the resulting 404 may inherit the wrong caching policy.

2. Force dynamic rendering for routes where 404 caching must never be reused

This is the most reliable application-level workaround.

export const dynamic = 'force-dynamic'

import { notFound } from 'next/navigation'

export default async function Page({ params }) {
  const item = await getItem(params.slug)

  if (!item) {
    notFound()
  }

  return <div>{item.title}</div>
}

Why it works: force-dynamic prevents static optimization and pushes the route into request-time rendering, which avoids preserving stale cache metadata from a static path.

3. Disable fetch caching when the resource existence is request-sensitive

If the route depends on a backend result that may change frequently, disable caching at the fetch level too.

import { notFound } from 'next/navigation'

async function getItem(slug) {
  const res = await fetch(`https://api.example.com/items/${slug}`, {
    cache: 'no-store'
  })

  if (res.status === 404) return null
  if (!res.ok) throw new Error('Failed to fetch item')

  return res.json()
}

export const dynamic = 'force-dynamic'

export default async function Page({ params }) {
  const item = await getItem(params.slug)

  if (!item) {
    notFound()
  }

  return <div>{item.title}</div>
}

This prevents a cached backend miss from combining with route-level caching in a misleading way.

4. If you must keep ISR, isolate cacheable and not-found logic carefully

Some applications need ISR for performance. In that case, do not allow your main route to ambiguously behave like both a static page and a highly dynamic existence check.

A common pattern is to move existence-sensitive logic behind a dynamic boundary or route handler.

export const revalidate = 300

import { notFound } from 'next/navigation'

async function getStableContent(slug) {
  const res = await fetch(`https://api.example.com/content/${slug}`, {
    next: { revalidate: 300 }
  })

  if (res.status === 404) return null
  if (!res.ok) throw new Error('Failed to fetch content')

  return res.json()
}

export default async function Page({ params }) {
  const content = await getStableContent(params.slug)

  if (!content) {
    notFound()
  }

  return <article>{content.title}</article>
}

If this still produces incorrect cache headers in your environment, prefer the dynamic workaround instead of trying to tune around the framework behavior.

5. Verify the response headers explicitly

Do not trust the visible UI alone. Check the actual HTTP response.

curl -I http://localhost:3000/example-200

You are looking for:

  • HTTP/1.1 404 or equivalent,
  • a Cache-Control value that matches your intent,
  • and no accidental long-lived caching for missing content.

For local debugging in Node, you can also inspect through browser devtools or an HTTP client.

6. Prefer route handlers when you need full header control

If a route is fundamentally an existence check or custom error surface, a Route Handler gives you direct control over status and headers.

import { NextResponse } from 'next/server'

export async function GET() {
  return new NextResponse('Not Found', {
    status: 404,
    headers: {
      'Cache-Control': 'no-store, max-age=0'
    }
  })
}

This is not a drop-in replacement for every page route, but it is the cleanest fallback when precise caching behavior is non-negotiable.

7. Track the upstream framework fix

Because this is framework behavior, the long-term solution is a Next.js patch that recalculates or overrides Cache-Control correctly whenever notFound() produces the final response. Until then, application-level workarounds are the practical path.

Common Edge Cases

Static params plus runtime misses

If you use generateStaticParams(), some paths may be pre-rendered while others resolve at runtime. A slug omitted from static params may still hit a branch that throws notFound(), and header behavior can differ between those cases.

ISR and deleted content

A page that existed during build or a previous revalidation window can later disappear in the data source. When the next request turns into a 404, stale cache policies may continue to reflect the earlier successful page mode.

CDN amplification

Even if the browser cache impact seems minor, a reverse proxy or CDN can amplify the problem by caching the unexpected 404 header set across many users.

Mixed fetch strategies

Combining cache: ‘force-cache’, next: { revalidate: … }, and route-level dynamic settings can produce non-obvious outcomes. Keep your caching model consistent within routes that may call notFound().

Custom not-found UI

A custom not-found.tsx changes presentation, not necessarily the response caching rules. The visual error page can look correct while the underlying header is still wrong.

Middleware assumptions

If middleware rewrites or annotates requests, it may obscure whether the 404 came from the original route, a rewrite target, or a late render failure. Always inspect the final network response.

FAQ

Why is the status code correct if the cache header is wrong?

Because those are finalized through related but separate response decisions. The route can correctly throw notFound() and set the final status to 404, while previously computed caching metadata still leaks into the response headers.

Is using notFound() itself the problem?

No. notFound() is the correct App Router API for rendering a 404 state. The bug appears when that 404 is produced inside a route whose caching mode was derived earlier as static, revalidated, or otherwise cacheable.

What is the safest workaround in production?

If incorrect 404 caching can cause stale content, SEO issues, or proxy poisoning, the safest workaround is export const dynamic = ‘force-dynamic’ combined with cache: ‘no-store’ for existence-sensitive fetches. It is less cache-efficient, but it avoids serving a wrongly cached not-found response.

The practical takeaway is simple: if an App Router page can legitimately end in notFound(), do not assume the framework will always emit a 404-safe Cache-Control header under static or ISR conditions. Verify the header, and if necessary, force dynamic behavior until the upstream fix lands.

Leave a Reply

Your email address will not be published. Required fields are marked *