Founder & Lead Engineer, RAITHub
If notFound() in your Next.js App Router returns a 200 instead of a 404, look for a loading.tsx above the route. It wraps the page in a Suspense boundary, so Next.js starts streaming, and sends the 200 status, before notFound() runs. On this site, deleting the root app/loading.tsx and scoping it to app/admin made unknown URLs return a real 404.
This is a first-hand incident from the RAITHub website, which runs Next.js 16 on the App Router. Unknown /blog slugs rendered the "not found" page but answered with HTTP 200. We confirmed the cause and the fix with curl against a production build, not the dev server. This post explains why it happens, how to check your own site in two minutes, and how to keep loading states without breaking status codes.
What is a soft 404, and why should you care?
A soft 404 is a page that tells people "this does not exist" while the HTTP status says "200 OK". Google's documentation says that if a page's content suggests an error, an empty page or an error message, Search Console will show a soft 404 error, and it recommends a real 404 or 410 status for pages that do not exist (Google Search Central: HTTP status codes).
Next.js softens the SEO damage. When a 404 page is streamed, it adds <meta name="robots" content="noindex"> to the HTML, and the docs state that "in the streaming case, this does not lead to indexation because the page is explicitly marked noindex" (Next.js: loading.js). So the missing page is unlikely to be indexed. The status code is still wrong, and that matters in other places:
- Search Console reports fill with soft 404 warnings, which hide real problems.
- Uptime and link checkers see 200 and report broken links as healthy.
- Analytics and logs cannot count 404s, so you never learn which old URLs people still hit.
- CDN and proxy rules that treat 404s differently, for example with a shorter cache time, never fire.
- Redirect planning after a migration depends on knowing which URLs 404. A site that never 404s hides that list.
Why does loading.tsx make notFound() return 200?
Because streaming has to send the response headers first, and the status code is one of them. Once the first byte of HTML leaves the server, the status cannot change.
The Next.js docs set out the chain. A loading.js file "will automatically wrap the page.js file and any children below in a <Suspense> boundary", and in the component hierarchy it wraps not-found.js, page.js and nested layouts. On status codes, the same page says: "When streaming, a 200 status code will be returned", and "because the response headers have already been sent to the client, the status code of the response cannot be updated" (Next.js: loading.js).
So the sequence for an unknown slug was:
- The request for
/blog/does-not-existarrives. - The page starts an async lookup: is this a repo post, or a row in the database?
- While that lookup is pending, the root
loading.tsxfallback renders. Rendering a fallback starts the stream, so the headers go out with status 200. - The lookup finds nothing and the page calls
notFound(). Next.js swaps in the not-found UI and the noindex tag, but the 200 is already on the wire.
The docs give the rule directly: "Place notFound() before those boundaries and before any await that may suspend." A loading.tsx at the root of app/ puts a boundary above every page on the site, so no page can satisfy that rule.
Which setups return a real 404?
It depends on whether the status can be decided before anything streams. This table summarises the cases, based on the Next.js documentation and what we observed on this site.
| Setup | Status for an unknown URL | Why |
|---|---|---|
Root app/loading.tsx, page awaits data then calls notFound() | 200, with a noindex meta tag | The fallback starts the stream before the lookup finishes (this site, before the fix) |
No loading.tsx above the route, notFound() in the page or generateMetadata | 404 | Nothing streams until the page resolves, so the status is still open (this site's /blog/[slug], after the fix) |
generateStaticParams plus dynamicParams = false | 404 | Unlisted params are rejected before rendering (this site's /industries/[slug]) |
notFound() called inside a component under its own <Suspense> | 200, with a noindex meta tag | Same streaming rule, from a boundary you added yourself |
| Proxy checks existence and returns a 404 response | 404 | The response is produced before any rendering; the docs suggest this and warn to keep the check fast |
How do you check whether your Next.js site has this bug?
Request a URL that cannot exist, from a production build, and read only the status code. Do not trust the dev server or the browser: the page looks identical either way.
# Build and serve exactly what you deploy
npm run build
npm start
# In another terminal: print only the status code
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/blog/this-slug-does-not-exist
# Healthy: 404
# Bug: 200
If you get 200, confirm the cause by looking for the tag Next.js injects when it streams a 404:
curl -s http://localhost:3000/blog/this-slug-does-not-exist | grep -o '<meta name="robots" content="noindex">'
A 200 status with that tag in the body is the streaming soft 404. A 200 without it usually means the page rendered a "not found" message without ever calling notFound(), which is a different bug. Then list every loading.tsx in the project and note which routes each one covers:
find src/app -name "loading.tsx"
Repeat the curl check on the live site after deploying. Test each dynamic route type separately: blog posts, product pages, profiles, anything with a [slug] or [id].
What was the fix on this site?
We deleted the root app/loading.tsx and moved the loading state to app/admin/loading.tsx, the only area that needed it. After a fresh production build, unknown /blog slugs returned 404.
The admin area keeps its boundary for a specific reason, recorded in the file itself:
// src/app/admin/loading.tsx
// Scoped to /admin on purpose. A root-level loading.tsx wraps every public
// route in Suspense, so the 200 status is streamed before notFound() can run and
// unknown URLs became soft 404s. Admin pages (noindex) keep the boundary, which
// useSearchParams() on the login and editor pages requires.
export default function Loading() {
return (
<div role="status" aria-label="Loading page">
<p>Loading...</p>
</div>
)
}
Admin pages are noindex and behind login, so their status codes do not affect search. Public pages lost very little: they are served as cached HTML with incremental static regeneration (ISR), a page rebuilt in the background on a timer, so the fallback rarely showed anyway.
The blog page itself was already written to 404 correctly. Both the page and generateMetadata go through one helper, so an unknown slug and a database failure both end in notFound():
// src/app/blog/[slug]/page.tsx (trimmed)
async function loadPostOr404(slug: string): Promise<RenderPost> {
let post: RenderPost | null = null
try {
post = await loadPost(slug)
} catch {
post = null
}
if (!post) notFound()
return post
}
The code was right; the file above it was the problem. That is why this bug survives code review: nothing in the page looks wrong.
How do you keep loading states without soft 404s?
Decide whether the resource exists before anything can stream, then show loading UI only for the slow parts. In practice:
- Scope
loading.tsxto the routes that need it. Dashboards and admin screens behind login are good candidates. Public, indexable routes with dynamic params usually are not. - Use route groups such as
app/(dashboard)/loading.tsxto give a set of routes a loading state without putting it at the root. - Check existence first, then suspend. Run the cheap "does this slug exist?" lookup and call
notFound()at the top of the page, then wrap slower sections such as comments or related items in their own<Suspense>below it. - Close the set when it is fixed. If every valid slug is known at build time,
dynamicParams = falseturns every other slug into a 404. This site does that for industry pages. - Use the proxy for the rare case that needs it. The docs suggest rewriting missing slugs to a not-found route in proxy, but warn: "Keep proxy checks fast, and avoid fetching full content there."
If you use Cache Components, the same rule applies. The status can only be set before streaming starts, so run the curl check after enabling it rather than assuming.
How do you stop it coming back?
Make the status code a test, not a memory. A check of this shape, run against a production build in CI, fails the moment someone adds a root loading file again:
// e2e/status-codes.spec.ts (Playwright)
import { test, expect } from '@playwright/test'
for (const path of ['/blog/no-such-post', '/work/no-such-case-study']) {
test(path + ' returns a real 404', async ({ request }) => {
const res = await request.get(path)
expect(res.status()).toBe(404)
})
}
Add one line per dynamic route type. It takes seconds to run and covers a failure that no type-check, lint rule or unit test will catch. The pre-launch QA checklist lists this alongside the other checks worth automating before go-live.
Why RAITHub for this kind of bug?
Because we found this one on our own production site and proved the fix with the status code, not the screenshot. Bugs like this are invisible in the browser and in code review; they show up only when someone tests the HTTP contract.
- Verification on a production build. The dev server renders on demand and hides caching and streaming differences, so we test what ships.
- Regression tests with the fix. A status-code bug gets a status-code test in CI.
- Next.js App Router experience in production. This site runs Next.js 16 with ISR, cached queries and a Node.js proxy. Our Next.js development service applies the same checks to client builds.
When you don't need us
- You have one root
loading.tsxand no reason for it. Delete it, rebuild, and run the curl check above. That is a ten-minute fix. - The page renders a "not found" message but never calls
notFound(). That is a code fix in one component, not a framework issue. - Your site is fully static. A static export serves real files, and missing files are handled by your host.
If the status is still wrong after removing the boundary, or you have many dynamic routes to audit, see how a fixed-price fix works or read the Next.js production checklist. To hand it over, send us the URL and the curl output for a free 15-minute technical audit.
Tested on Next.js 16 (App Router) with a production build. Last reviewed: 29 September 2026.
Frequently asked questions
Why does Next.js notFound() return a 200 status?
Because the response had already started streaming. A loading.tsx or <Suspense> boundary above the call renders its fallback first, which sends the headers with status 200. After that, notFound() can change the page content but not the status.
Is a soft 404 in Next.js bad for SEO?
Less than it sounds. Next.js adds a noindex meta tag when it streams a 404, so the page should not be indexed. The wrong status still clutters Search Console with soft 404 reports and hides missing URLs from monitoring, analytics and redirect planning.
Should I delete loading.tsx?
Delete it from the root of app/ if public routes with dynamic params sit under it. Keep loading files in the specific segments that need them, such as dashboards behind login, where status codes do not affect search.
How do I test the HTTP status of a Next.js page?
Run npm run build and npm start, then curl -s -o /dev/null -w "%{http_code}" against a URL that cannot exist. Check the live site the same way after deploying. The browser shows the same page for 200 and 404, so it cannot tell you.
Does dynamicParams = false fix soft 404s?
For routes where every valid param is known at build time, yes. With generateStaticParams and dynamicParams = false, unlisted params return 404. It does not help routes whose valid values come from a database at request time.
Can I return a 404 from proxy.ts instead?
Yes. The Next.js docs suggest checking in proxy and rewriting missing slugs to a not-found route or returning a 404 response. Keep the check to a fast existence lookup, because proxy runs before every matched request.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.