Soft 404 in Next.js: Why notFound() Returns 200
A soft 404 in Next.js is a missing page that answers with HTTP 200 instead of 404. It happens when notFound() runs after the response has started streaming, which means any route under a loading.tsx or a <Suspense> boundary. For a real 404, confirm the page exists before anything streams.
ChatGPT's crawler spends 34.82% of its fetches on URLs that return 404, and Claude's spends 34.16%, according to Vercel and MERJ's December 2024 analysis of AI crawler traffic. Bots ask for pages that don't exist all day long. What your server says back matters more than the 404 page you designed.
So we built a throwaway Next.js 16.4 app, wired up eight different ways of saying "not found", and wrote down the status code each one sent. Under the config that create-next-app writes today, two of the eight returned a 404. The other six said 200. If you searched for a soft 404 in Next.js because Search Console flagged one, the table below will tell you which pattern you shipped.
Soft 404 vs real 404: same page on screen, different first line
A soft 404 is a URL that tells the visitor the page doesn't exist while the server answers with a 200 success code. A real 404 is the same message with status 404 on the response. Nobody looking at the browser can tell them apart, which is why the bug survives every manual review.
"This page could not be found." Branded, friendly, with a link back to the homepage.
HTTP/1.1 200 OK. The same answer your pricing page gives.
Next.js adds a third case that most write-ups skip. A streamed 404 is a response with status 200, the not-found UI, and a robots noindex tag that the framework injects for you. It's a soft 404 by the letter and a handled one in practice, and the difference between it and an unhandled soft 404 is most of what's worth knowing here.
Google's position is plain. Its crawling documentation says soft 404 pages are excluded from Search, and that serving them spends crawl activity that could have gone to pages you care about. On a site with 40 real URLs, a crawler burning requests on an unbounded set of fake ones is a bad trade. It's also one of the quieter reasons a new site struggles to get indexed by Google at all.
Eight ways to say "not found" in Next.js 16.4, and what each one sends
The setup: Next.js 16.4.0 with React 19.3.0, a production build served by next start, one dynamic route per pattern, each requested with a slug that isn't in the data. We ran it twice on 10 October 2026. Once with the config the scaffold produced (it wrote cacheComponents: true into next.config.ts), once with Cache Components switched off.
| # | Not-found pattern | Cache Components off | Cache Components on (scaffold default) |
|---|---|---|---|
| 1 | URL matches no route at all | 404 | 404 |
| 2 | notFound() in the page, nothing streaming above it | 404 | 200, noindex in the head |
| 3 | notFound() with a loading.tsx in the segment | 200, noindex after </head> | 200, noindex after </head> |
| 4 | notFound() inside a <Suspense> child | 200, noindex after </head> | 200, noindex after </head> |
| 5 | Page renders its own "Page not found" text, never calls notFound() | 200, no noindex | 200, no noindex |
| 6 | redirect('/') when the record is missing | 307 to the homepage | 200, redirect delivered inside the stream |
| 7 | generateStaticParams with dynamicParams = false | 404 | Build error: option not allowed |
| 8 | Existence check in proxy.ts | 404 | 404 |
One Next.js version, one machine, next start on localhost. Your host, your version and your caching layer can change the answer, so treat the table as a map of where to look and run the curl check further down against production.
If you'd rather not start with a terminal, the missing-states tool sends the pattern-1 request to any URL you give it and reports the status that came back.
Why notFound() returns 200: the status leaves before your data arrives
notFound() returns 200 in Next.js when it runs after the response has started streaming. A loading.tsx file or a <Suspense> boundary sends the page shell first, with status 200, and an HTTP status can't change once it has been sent. Next.js renders the not-found UI and injects a noindex tag instead.
The mechanics are simple. An HTTP response opens with its status line, and streaming exists so the server can send the shell immediately and fill in the slow parts afterwards. The status goes out before your database has said whether the record exists. The Next.js docs put it in one line: streaming page responses begin with a 200 status code.
Two things start that stream. A loading.tsx file does, because Next.js wraps the page in a Suspense boundary with your skeleton as the fallback. A hand-placed <Suspense> around a data-fetching component does the same. So a founder who adds a loading skeleton to /blog/[slug] on a Tuesday has, without touching a single line of 404 code, turned every missing post from a 404 into a 200. That's pattern 3, and it's the one we'd bet on if you're reading this after a Search Console email.
Cache Components makes streaming the default
Here's what surprised us. With Cache Components on, the build refused our plain blocking page outright. The error listed three ways out, in this order: wrap the data access in <Suspense>, cache it, or opt the route into blocking with export const instant = false. A coding agent fixing that error reaches for the first suggestion, which is the one that produces 200s. And when we took the third option instead, pattern 2 still returned 200, because the prerendered shell is sent first either way. The noindex tag landed in the head that time, which is the only improvement.
Check your next.config.ts. If an agent scaffolded the app in the last few months and cacheComponents: true is in there, assume every notFound() inside a dynamic route answers 200 until you've proven otherwise.
Where the noindex tag ends up
In patterns 3 and 4 the tag arrived after </head>, inside the streamed part of the document. A browser, or a crawler that renders JavaScript the way Googlebot does, runs the inline script that moves it into place. A parser that reads the raw HTML once finds a meta tag sitting in the body, where the HTML spec doesn't expect one. Whether it honors the tag is that parser's decision, and you don't get a vote.
Does a streamed 404 with noindex hurt you?
For Google rankings: barely. The same docs page says some crawlers may label these responses soft 404s and that the noindex keeps them out of results. We'd agree.
The cost shows up with everything that isn't Googlebot.
| Who is asking | What a 200 not-found does to them |
|---|---|
| Googlebot (renders JavaScript) | Keeps the URL out of the index. In Search Console it can surface under "Soft 404" or "Excluded by noindex tag" instead of "Not found (404)", which makes the report harder to read |
| AI crawlers (GPTBot, ClaudeBot, PerplexityBot) | The Vercel and MERJ study found none of the major ones execute JavaScript. They get a 200 and a document, and the noindex may be sitting in the body |
| Link checkers and uptime monitors | They trust the status code, so a dead internal link pointing at a soft 404 passes as healthy |
| Your own logs and analytics | Your 404 rate reads as zero. You lose the one signal that tells you which old URLs people and bots still request |
The AI row is getting heavier. Ahrefs checked 16 million URLs cited by AI assistants and found they send people to dead pages 2.87 times as often as Google Search does, partly because assistants invent plausible-looking URLs. Your handling of a path that never existed is now a page real visitors land on. (If AI crawlers are your main worry, the wider checklist for making a site readable to AI covers the rest of what they can and can't see.)
Then there are the two patterns with no safety net. Pattern 5 is the one agents write constantly: if (!post) return <p>Post not found</p>. It returns 200, there's no noindex because notFound() was never called, and every garbage slug becomes an indexable thin page. Pattern 6, sending every missing slug to the homepage, is the same problem wearing a redirect. Google's John Mueller has said that redirecting old pages in bulk to the homepage gets treated as a soft 404, and under Cache Components our redirect didn't even send a Location header. It was a 200 with the redirect instruction buried in the payload.
How to return a real 404 in Next.js
Four fixes, strongest first. The first one worked in both configs we tested. The rest depend on which config you're running.
- Decide in
proxy.ts, before rendering starts. This was the only application-level pattern that returned 404 in both runs, with aloading.tsxstill in the segment. Keep the lookup cheap (a Set built at deploy time, a key-value read), because it runs on every matching request. What goes wrong: fetching the full record here and doubling your database load.
The plain-text body is ugly. The Next.js docs also describe rewriting to a not-found route from the proxy if you want the branded page; we tested the bare response.// proxy.ts import { NextResponse, type NextRequest } from 'next/server' const KNOWN = new Set(['hello-world', 'pricing-update']) export function proxy(request: NextRequest) { const slug = request.nextUrl.pathname.split('/')[2] if (!KNOWN.has(slug)) { return new NextResponse('Not found', { status: 404 }) } } export const config = { matcher: '/blog/:slug' } - With Cache Components off, keep the check above every boundary. Call
notFound()in the page component, and make sure noloading.tsxsits in that segment or any parent segment. Move<Suspense>down so it wraps the slow parts after the existence check has passed. What goes wrong: a teammate adds a skeleton three months later and pattern 2 quietly becomes pattern 3. - For a closed set of slugs, close the route.
generateStaticParamsplusexport const dynamicParams = falsegave us a 404 for anything outside the list. It only exists in the classic config. With Cache Components on, the build rejects the option. - Never hand-render the message. Replace every
return <p>Not found</p>withnotFound(). In a streaming route you still get a 200, but you also get the noindex and the not-found boundary, which moves you from pattern 5 to pattern 3. That's the biggest improvement per line changed in this whole list.
Two side notes. Content that moved deserves a permanent redirect to its real replacement, declared in redirects() in your config or in the proxy, where it's a true HTTP response instead of something streamed. And if you're still on the Pages Router, returning { notFound: true } from getStaticProps or getServerSideProps sends a 404 without any of this ceremony.
Vite, Lovable, Bolt: when every URL is a 200
A client-rendered React app has it worse, and there's no notFound() to call. The host rewrites every path to index.html (that rewrite is what makes a deep link survive a refresh), so the server has no concept of a missing page. Your router shows a not-found component, the status is 200, and it's 200 for every string anyone can type after your domain.
Google's JavaScript SEO guide gives two workarounds for a soft 404 in a React SPA: redirect with JavaScript to a URL where the server does answer 404, or inject a noindex tag from the not-found component. Both need JavaScript to run. A crawler that doesn't run it receives your empty shell and a success code, every time.
The durable fix is structural: prerender or server-render the routes that exist and let the host return a real 404 for the rest. We go through that trade for one specific builder in the Lovable pre-launch checklist.
Find your soft 404s with curl before a crawler does
One command prints the status code and nothing else:
curl -s -o /dev/null -w "%{http_code}" https://yourdomain.com/blog/this-slug-does-not-exist
Run it against four kinds of URL, because each one exercises a different pattern from the table:
- Garbage at the root, like
/zz-not-a-page. A 200 here means the whole site has no 404 handling: the SPA fallback or a catch-all rewrite. - Garbage under every dynamic segment:
/blog/zz,/docs/zz,/u/zz. Patterns 2 to 6 live here, and each segment can behave differently depending on whether it has aloading.tsx. - A URL you deleted or renamed. It should return 404, or a 301 or 308 to its real replacement.
- The same three with
-A "GPTBot". Proxies, bot protection and edge rules sometimes answer bots differently from browsers.
Then open Search Console, go to Page indexing, and read the "Soft 404" row and the "Excluded by noindex tag" row together. In a streaming Next.js app, your missing pages are split across both.
What the Tabkeel exam checks here, and what it doesn't
The missing-states front of the exam sends one request for a path no legitimate site would have, then grades what comes back on a four-step scale.
| Response to the probe | Severity | What it means |
|---|---|---|
| 200, and the body matches the homepage | High | No 404 handling exists anywhere |
| 200, with a different body | Medium | Soft 404 |
| 404, under 60 characters and no navigation | Low | A dead end for whoever lands there |
| Any status, with a stack trace in the page | High | The error page leaks file paths and internals |
The top row is the SPA fallback, and the evidence line on that finding reads: "HTTP 200 and the returned page is the same as the home: there is no handling for nonexistent routes." The fix arrives as a prompt for whichever agent built the site:
A nonexistent route returns HTTP 200 with the home page: there is no 404 handling. Add a real 404 page that returns status 404 with navigation back. Without it, search engines index errors as content and users hit a dead end.
Now the limit. That probe is a root-level path, which is pattern 1. It catches the site-wide failures and it won't see a soft 404 that only exists inside /blog/[slug], which is exactly why the curl list above has a step 2. The same honesty applies to links: any checker that reads status codes, including the broken links tool we ship, will count a link to a soft 404 as alive. Fix the status codes first and your link report starts meaning something.
When you want the root probe plus the other six fronts in one crawl, paste your domain into the Tabkeel exam. Every finding and the evidence behind it is on the free tier; the complete set of paste-ready fix prompts is what Founder adds. And once missing pages stop answering 200, the pages left in the index are the ones worth pushing: Tabkeel's Search analytics connects to Search Console and ranks the striking distance keywords a short climb from page one.
Frequently asked questions
Why does notFound() return 200 in Next.js?
Because the response started streaming before notFound() ran. A loading.tsx file or a Suspense boundary sends the page shell immediately with status 200, and an HTTP status can't be changed once it's sent. Next.js renders the not-found UI and injects a noindex tag instead. Calling notFound() before anything streams, or checking existence in proxy.ts, returns a real 404.
Does a soft 404 hurt SEO?
Google excludes soft 404 pages from Search and says serving them uses crawl activity that could go to real pages. The larger risk is the unhandled kind: a page that prints its own not-found message with status 200 and no noindex tag can be indexed as thin content, and every invalid URL creates another one.
Is the noindex tag Next.js adds enough to make a streamed 404 safe?
For Google, mostly yes: the page stays out of search results. For everything else, no. In our Next.js 16.4 test the tag arrived after the closing head tag on streamed routes, AI crawlers that don't execute JavaScript receive a 200 with a document, and link checkers and uptime monitors treat the URL as healthy.
How do I check the status code my 404 page returns?
Run curl -s -o /dev/null -w "%{http_code}" followed by a URL that doesn't exist on your site. Test a garbage path at the root and one under each dynamic segment such as /blog/ or /docs/, since each segment can behave differently. Anything other than 404, or a 301 or 308 for moved content, needs a look.
Do React single-page apps always return soft 404s?
A client-rendered SPA behind a catch-all rewrite to index.html does, because the server answers 200 for every path and the router decides what to show afterwards. Google suggests a JavaScript redirect to a server-side 404 URL or a JavaScript-injected noindex tag. Both depend on the crawler running JavaScript, so prerendering or server-rendering the real routes is the lasting fix.
See what AI and Google read on your site
The exam crawls your public site and returns every finding with the evidence and a paste-ready fix. Free, no signup.
Run the free examMore articles