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:
// 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} />
}
// app/projects/[slug]/loading.tsx
export default function Loading() {
return <ProjectSkeleton />
}
Check the status with curl:
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:
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:
// 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:
- List the routes with a dynamic segment and a
loading.tsxnext to them. - Request a nonsense slug on each one from a production build and read the status code with curl.
- Anything that prints
200with a not-found page is a soft 404. - Fix it with
dynamicParams = falseif 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, and the project pages that carry the fix are listed on /projects.
Top comments (0)