# Next.js soft 404: why notFound() returned 200 behind loading.tsx

On my Next.js 16 site, a URL like `/projects/nope` returned HTTP 200 and carried two robots tags: `index, follow` and `noindex`. The route has a `loading.tsx`, which starts streaming before `notFound()` runs, so the status code was already sent. Adding `export const dynamicParams = false` to the page makes an unknown slug a real 404 before any rendering starts.

## Why does notFound() return 200 in the Next.js App Router?

I found this during a Search Console pass. Unknown project slugs were not 404s. The Next.js docs describe the mechanism in the `loading.js` reference: streaming page responses begin with a `200` status code, and the initial response headers fix the status for the rest of the response. Next.js can still stream `notFound()` after that, so the browser shows your not-found UI, but the status line stays `200`.

The docs also say when the stream starts. The response body begins streaming when a Suspense fallback renders, and they give `loading.tsx` as the example, or when a Server Component suspends under a `<Suspense>` boundary. Calling `notFound()` before that point preserves the `404`. After it, the status cannot change.

A `loading.tsx` file is a Suspense fallback wrapped around the segment's page. For a dynamic route, that means the fallback can be sent before your page code has a chance to look up the slug and decide it does not exist.

## What does a soft 404 look like for Google?

A soft 404 is a page that tells the user "not found" but tells the crawler `200 OK`. Next.js softens the damage: the docs say it injects `<meta name="robots" content="noindex" />` into a streamed 404, so search engines are told to keep the URL out of the index even though the status is 200. They also note that some crawlers may label these responses as soft 404s.

On my site the result was messier. The root layout already set `index, follow`, and the streamed not-found added `noindex`. The page carried both tags with a 200 status. I did not want to find out which one a crawler would trust, and I did not want Search Console reporting junk URLs as soft 404s.

## How do I reproduce it?

Here is the shape of the broken segment. The names are simplified from my project route:

```tsx
// app/projects/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const project = findProject(slug)
  if (!project) notFound() // too late: loading.tsx already streamed a 200

  return <ProjectView project={project} />
}
```

```tsx
// app/projects/[slug]/loading.tsx
export default function Loading() {
  return <ProjectSkeleton />
}
```

Check the status with curl:

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/projects/nope
```

You want `404`. In my case it printed `200`. To see the duplicate robots tags, fetch the body:

```bash
curl -s https://example.com/projects/nope | grep -o '<meta name="robots"[^>]*>'
```

## What fixed it: dynamicParams = false

Every project on my site is already prerendered through `generateStaticParams`. The fix was one line in the page file, with a comment I left for my future self:

```tsx
// app/projects/[slug]/page.tsx

// Every project is prerendered below, so an unknown slug is a 404 at routing.
// Left on, it rendered on demand and hit notFound() after [slug]/loading.tsx
// had already streamed a 200: a soft 404 carrying both index and noindex.
export const dynamicParams = false

export async function generateStaticParams() {
  const slugs = await listProjectSlugs()
  return slugs.map((slug) => ({ slug }))
}
```

The reference for `dynamicParams` says that with the default of `true`, segments not returned by `generateStaticParams` are generated at request time, and with `false` they return a 404. An unknown slug never reaches your page component, so `loading.tsx` never streams for it, and the response is a real 404 from routing.

After the change, an unknown slug is a real 404 from routing. The curl check against `/projects/nope` on my live site now prints `404`.

I left `loading.tsx` in place. At one point I suspected both loading files of bloating the DOM and removed them to test it. The DOM went from 951 to 949 nodes, so they went back. The soft 404 was their only real cost, and `dynamicParams` fixed that without touching them.

## When is dynamicParams = false the wrong fix?

It fits when you know every valid slug at build time. If your slugs come from a database that changes after deploy, `false` would 404 a page that was added after the build, until you rebuild or revalidate.

The docs also list a restriction: `dynamicParams` is not available when Cache Components is enabled. I did not test that configuration, so check your own setup if you use it.

For a route that has to render on demand, the docs give a different answer. Run the check before the body streams. For a 404, you can check the slug in Proxy, then rewrite to a not-found route or return a 404 response, and the docs say to keep Proxy checks fast and avoid fetching full content there. I use a proxy for a related case on this site: malformed UTF-8 in a URL, such as `/projects/%C0%AF`, used to be a 500 on decode. `proxy.ts`, which is the Next 16 name for middleware, now returns a 404 for those. Its matcher only runs on dynamic segments that contain a `%`, so no other request pays for it.

The other option is to call `notFound()` before any `await` on a slow path and before any `<Suspense>` boundary. The streaming guide recommends exactly that placement to get a real status code.

## How do I check every dynamic route for this?

The rule I wrote down after this: any dynamic route with a `loading.tsx` must 404 without rendering. That means `dynamicParams = false`, or a proxy check.

To audit a site:

1. List the routes with a dynamic segment and a `loading.tsx` next to them.
2. Request a nonsense slug on each one from a production build and read the status code with curl.
3. Anything that prints `200` with a not-found page is a soft 404.
4. Fix it with `dynamicParams = false` if the slug set is known at build time, or with a proxy check if it is not.

The site this came from is documented in the [portfolio case study](https://www.mohamedgado.com/projects/portfolio), and the project pages that carry the fix are listed on [/projects](https://www.mohamedgado.com/projects).
