# Soft 404: why a missing page answers 200 OK

> An address that never existed should get status 404. When it gets 200, Search Console calls it a soft 404. Where the 200 comes from, and a safe fix.

Published 2026-11-12 · https://aiskills402.com/blog/soft-404-missing-page-answers-200

A soft 404 is an address that leads nowhere and still gets status 200, usually with your home page, an empty app shell or a polite "page not found" text inside it. Search Console flags it because the content reads like an error while the status claims success ([Google Search Central](https://developers.google.com/search/docs/crawling-indexing/http-network-errors)). Rewording the error page fixes nothing. What decides is the first piece of your stack to respond: a rewrite at the host, a fallback in the web server, a catch-all in the app, an error page that swaps the status, or a framework that has already started streaming. This guide shows how to find that piece and change the status there, without the side effect the obvious fix brings: every real deep link of a single-page app failing on reload.

## See it with two requests

No crawler is needed to confirm the problem. Ask for an address that cannot exist and for a real page deep in the site, and read only the status line:

```
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/zz-no-such-page-7f3
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/shop/blue-teapot
```

The first should print `404`, the second `200`. People skip the second request, yet it is what gives the check its value: if both print `404` after your change, you have not cured anything, you have taken the site down.

## Why the page text does not count

Google's page on status codes explains that content served with status 200 moves on towards indexing, and when what arrives looks like an error or holds nothing at all, Search Console reports a soft 404 ([Google Search Central](https://developers.google.com/search/docs/crawling-indexing/http-network-errors)). The signal Google wants is the status itself.

That status is settled by whichever part of the stack replies first. Start at the edge, with the hosting platform and any CDN in front of it, then the web server, then your own server code, then the framework, and the browser last. A router component that draws a "not found" screen runs in the browser, so by the time it renders, the success status has already been delivered. It improves what the visitor sees and leaves the status alone.

## Layer one: a catch-all rewrite at the host

This cause shows up most often, because hosting guides recommend the rule for single-page apps. Netlify's advice for apps built on the History API is a rule that routes everything to `index.html` with status 200, normally placed last in the file. Real files still win: a rewrite cannot shadow a file that exists, except when the rule carries the force flag ([Netlify rewrites](https://docs.netlify.com/routing/redirects/rewrites-proxies/)). Netlify also uses a `404.html` on its own, with no rule, whenever an address matches no file ([Netlify](https://docs.netlify.com/routing/redirects/redirect-options/)). Put the two side by side and the problem is plain: the wildcard grabs every unknown address before the not-found page gets its turn. Rewrite only the prefixes your app really owns, such as `/app/*` or `/shop/*`, and leave the `404.html` in place for everything else.

Other hosts have the same shape with different switches:

- **Firebase Hosting.** The documented single-page rule uses `"source": "**"` and hands back `index.html` for any file or folder that is missing. Firebase ranks configured rewrites above the custom not-found page, so as long as the rule stays, your `404.html` is never used. Limit `source` to the routes the app has ([Firebase](https://firebase.google.com/docs/hosting/full-config)).
- **Cloudflare Pages.** Without a `404.html` at the top level, Pages treats the project as a single-page app, giving the root page to every address that matches no asset. Adding the file switches that off, which is the cure, but then each route the app draws needs its own prerendered HTML file, or its deep link ends on the not-found page as well ([Cloudflare Pages](https://developers.cloudflare.com/pages/configuration/serving-pages/)).
- **Cloudflare Workers static assets.** When the Wrangler file says `not_found_handling = "single-page-application"`, a request with no matching file receives the contents of `/index.html` and status 200 ([Cloudflare Workers](https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/)). The `404-page` value replies with the nearest not-found page and the right status, again on the condition that every real route exists as a file.
- **nginx.** In `try_files`, the final parameter is where nginx goes when nothing before it matched, and it may be a status code instead of a file, so `try_files $uri $uri/ =404;` gives a proper not-found reply ([nginx](https://nginx.org/en/docs/http/ngx_http_core_module.html)). Keep the jump to `/index.html` inside the `location` blocks of the app's own routes.

## Layer two: application code that sends the shell

On your own server the catch-all is usually one line, a handler for `*` that returns `index.html`. Express points out that a missing page is not an error in its model: when no route or middleware has replied, you register one more function, as the last one, and it sets status 404 ([Express FAQ](https://expressjs.com/en/starter/faq.html)). Serve the app's page from the routes that really belong to it, and let that final function reply to everything else. A route that prints "nothing here" without setting the status is the same mistake in a smaller form.

## Layer three: an error page that changes the status

Sometimes the not-found page exists and the configuration converts it into a success. In nginx, `error_page 404 =200 /empty.gif;` serves the page with status 200 on purpose, and `error_page 404 = /404.php;` lets the target pick the status. Pointing `error_page` at a full URL produces a redirect, with status 302 unless you choose another code ([nginx](https://nginx.org/en/docs/http/ngx_http_core_module.html)). Apache is no different: give `ErrorDocument` an address with a scheme, and Apache answers with a redirect, so, as its manual warns, the client never sees the original error status ([Apache](https://httpd.apache.org/docs/2.4/mod/core.html)). The visitor ends up somewhere that replies with status 200, and so does the crawler. Use a local path instead, such as `ErrorDocument 404 /errors/missing.html`.

## Layer four: Next.js after streaming has started

In the App Router, `notFound()` produces a real not-found status when it runs before the response begins. Once the response is streaming, the status line has already gone out. The Next.js documentation says streamed responses open with status 200, and that showing the `loading.js` fallback, or any `<Suspense>` fallback, is the moment the stream starts. A missing record found after that point still shows the not-found screen, but with status 200 plus an added `noindex` tag, and the same page admits that some crawlers call such responses soft 404s ([Next.js](https://nextjs.org/docs/app/api-reference/file-conventions/loading)). The cure is to look the record up before anything streams: in Proxy (the file older projects call middleware), or above the boundary. Caching brings its own surprises at this layer, and our notes on [how Next.js caches pages, tags and redirects](/blog/nextjs-cache-isr-tags-redirects) go through them.

## The trap in the obvious fix

"Delete the fallback" sounds right and breaks the site in a new way. A single-page app draws its own routes in the browser, so `/shop/blue-teapot` exists only as a route, not as a file. Without a rule that returns the app for it, a visitor who reloads that page or opens a shared link lands on the not-found page. So each fix above lists by name the routes that must keep working, and the second request in the check exists to prove they still do.

A rewrite also has no idea which posts are in your database. If the app owns `/blog/*`, then `/blog/no-such-post` matches the rule and gets the app with status 200, even after everything else is narrowed. Closing that last gap takes a server that checks the slug, or a prerendered file for every post; plan for it rather than expect the rewrite to cover it.

## When the host has no settings

Some site builders expose no server configuration at all. For apps that route in the browser, Google documents two workarounds: a script redirect to an address the server answers with status 404, or a `noindex` robots tag added by script on error views ([Google Search Central](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics)). Both keep error views out of the index. Neither changes what a random address returns, so the two-request check will still print `200`, and whoever asked for the fix should hear that.

## A check that can fail

After the change, run the two requests again and add one step: open the body of the not-found reply and confirm it is your not-found page rather than the home page under a different status. Then ask Search Console to validate the fix, or rerun whichever site check reported it. If the random address still gets status 200, the part you changed was not the first to respond; move one step outwards and look again. If the real route stops returning status 200, your new rule is too narrow.

Search engines are not the only reason to care. Uptime probes, link checkers and agents that fetch an address to see whether it exists all trust the status, and a site that returns status 200 for everything tells them every address is real.

## When this does not apply

Only addresses that never existed are covered here. Pages that used to exist and were removed are a different problem, solved with a redirect to the replacement or a gone status, not with a not-found page. The host details come from each host's documentation as read on 12 November 2026; hosts change defaults, and a host or server not listed here may order its rules differently, so test rather than trust this page. We did not measure how quickly Search Console clears a reported address after the fix; that depends on when Google crawls it again. And a CDN that caches responses may keep the old status 200 for a while, so check with a fresh random address each time.

## What to do next

A diagnosis of this kind does a narrow job well. It points to the piece of your stack that replies to an address nobody defined, writes the fix in the format that host expects while keeping every route the app has, and hands you a check with a control, so that a broken site cannot pass it. It also leaves alone the setups that are fine: a proxy rewrite for an API prefix, a permanent redirect for one retired address, an error page that keeps status 404.

We sell a skill for exactly this, [Soft 404 Fix](https://aiskills402.com/skills/soft-404-fix). You give it the line from your crawl report or Search Console and the hosting config or server code; it replies with one line per cause, each carrying a fixed code, the file, the reason and a fix that lists the routes it preserves, then one line of checks, or a single sentence saying it found nothing. To be straight about it: a capable model handed the same files usually locates the cause too. The value lies in the fixed shape of the reply, the control check every time and a clean reply for a clean setup, which matters most when a script reads the result. The store is at https://aiskills402.com.
