afaqrashid.com  ·  portfolio · 2026 ed.

Next.js notFound() Returning 200 Instead of 404? Tested Fix

In the Next.js App Router, notFound() returns a 404 only if it runs before the page starts streaming. Once the server has sent the first bytes, the status is already 200 and can't change. Next.js then renders your not-found page inside that 200 response and adds a noindex tag.

The docs say this, but not which setups cause it. I built five versions of a dynamic [slug] route on Next.js 16.4.4, ran next build and next start, and requested a slug that doesn't exist with curl. I sent each request twice, once as Chrome and once with the Googlebot user agent.

What returned 404 and what returned 200

Each page awaited a 300 ms lookup and called notFound() when nothing came back:

Setup                                          Chrome  Googlebot
generateStaticParams, unknown slug              404     404
Dynamic page, no loading.tsx                    404     404
Dynamic page + loading.tsx                      200     200
notFound() in generateMetadata + loading.tsx    200     200
notFound() inside a <Suspense> child            200     200

The Googlebot user agent made no difference. In every 200 case the missing page still rendered the not-found UI, and Next.js added the noindex tag.

Why loading.tsx changes the status

loading.tsx wraps the page in a Suspense boundary. Next.js sends the layout and the loading fallback right away, which commits the headers and the 200 status. Your page's await runs after that, so its notFound() is too late to set a 404.

A Suspense boundary you add yourself does the same thing. Everything inside it runs after the status has gone out.

What the 200 response contains

This is the HTML a missing page sent with loading.tsx in place, with the scripts removed:

<head>...<title>Test</title></head>
<body>
  <template id="B:0"></template><p>Loading…</p>
  <div hidden id="S:0">...</div>
  <meta name="robots" content="noindex"/>
</body>

The title is the layout's default, the visible content is the loading fallback, and the noindex tag comes at the very end of the body, not in the head. The not-found UI is filled in later by the streamed script.

Google calls a page that says it doesn't exist but returns 200 a soft 404. The noindex keeps the URL out of the index, but anything that checks only the status code sees 200: uptime monitors, link checkers and SEO crawlers won't list your dead links as broken.

The fix: check in the segment's layout

loading.tsx wraps the page, not the layout of the same segment. A check in that layout runs before anything streams, so its notFound() still sets a 404. Wrap the lookup in React's cache() so the layout, generateMetadata and the page share one call:

// lib/items.ts
import { cache } from "react";

export const getItem = cache(async (slug: string) => {
  // Your own lookup: database, CMS or fetch. Return null when missing.
  return db.item.findUnique({ where: { slug } });
});
// app/items/[slug]/layout.tsx
import { notFound } from "next/navigation";
import { getItem } from "@/lib/items";

export default async function ItemLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  if (!(await getItem(slug))) notFound();
  return children;
}

Keep loading.tsx and the page as they are. With this layout, the same build gave:

/items/real  200  title "Real", loading fallback streamed
/items/nope  404  noindex in the <head>

The real page still streamed its slow parts behind the loading fallback, and the lookup ran once per request. The 404 waited only for the existence check, about 300 ms in my test.

Other ways to get a real 404

  • Remove loading.tsx from routes that can 404, if you don't need the instant fallback. The dynamic page without it returned 404.
  • For pages you can list at build time, use generateStaticParams. An unknown slug returned 404 in my test.
  • Don't call notFound() from inside a Suspense child. Check that the item exists above the boundary, then pass it down.

Check your own routes

Request a slug that doesn't exist and print only the status:

curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/items/does-not-exist

If it prints 200, find the loading.tsx or Suspense boundary above that notFound() and move the check into the layout. To see whether a page also carries noindex, paste its HTML into the metadata checker below.

Related

Sources