notFound() was returning 200, and loading.tsx was why
A Next.js route rendered the right 404 page with the wrong status code. The cause was a file two directories away.
While moving this site to the App Router I checked something easy to forget: what a bad URL actually returns. /projects/does-not-exist rendered the correct "Project Not Found" page — and answered HTTP 200.
That is a soft 404. The page looks right to a person and looks like real content to a crawler, so the URL gets indexed as a valid page. Worth fixing.
What it was not
My first three guesses were all wrong, and each one cost a build:
export const revalidate— I assumed ISR caching was swallowing the status. Removed it. Still 200.- A nested
not-found.tsx— maybe a segment-level boundary handled the throw as an ordinary error. Deleted it. Still 200. - Metadata resolving first —
generateMetadataruns before the component, so perhaps it committed the response early. Made it callnotFound()too. Still 200.
Meanwhile a genuinely unmatched path like /totally/bogus returned a clean 404. So the router could produce one; my route just was not getting it.
Bisecting instead of guessing
I stopped theorising and built the route up from nothing. A page that calls notFound() synchronously: 404. Add an await: 404. Add a real database query: 404. Make it a [slug] segment: 404. Add generateMetadata, revalidate, a local not-found.tsx: still 404, every time.
At that point the scratch route was a near-copy of the real one and behaved correctly, which meant the cause lived outside the page file. The remaining difference was a sibling:
app/projects/
loading.tsx <-- this
page.tsx
[slug]/page.tsx
Adding a loading.tsx to the scratch route reproduced the 200 instantly.
Why
A loading.tsx wraps its entire segment in a Suspense boundary — including child routes. Next sends the fallback shell immediately so the user sees something, and the moment those first bytes go out the status line is already on the wire. notFound() can still swap in the 404 UI, but 200 has been sent and headers cannot be recalled.
The fix is to scope the boundary to the route that wants it. A route group does that without changing any URL:
app/projects/
(list)/
page.tsx -> /projects, keeps its skeleton
loading.tsx
[slug]/
page.tsx -> /projects/[slug], now returns a real 404
What I took from it
Streaming makes the status code part of your layout decisions, which is not where you expect to find it. If a route can 404, nothing above it should open a Suspense boundary first.
And the wider lesson: I burned four rebuilds on plausible theories, then found it in minutes by building the route up one feature at a time. Bisecting is not the slow path. It only feels slower than guessing.
