tech
How to fix: Missing URLs return real 404 (no soft-404)
Why this matters
A "soft 404" is when a URL that doesn't exist returns HTTP 200 (or redirects to the homepage) instead of a proper 404 / 410. Search Console can't distinguish these from real pages, so broken internal links stay hidden and crawl budget gets wasted probing ghost URLs. Google's own guidance flags soft-404 handling as an indexing-quality signal — a site with clean 404s ranks better than one with a fog of 200-OK phantom pages.
Background
A soft 404 is a URL that doesn't exist but still returns HTTP 200 (or 3xx to a real page) instead of a proper 404 / 410. Search Console can't distinguish soft-404s from real content, so broken internal links stay invisible, crawl budget gets spent probing ghost URLs, and the site can end up with hundreds of near-duplicate "page not found" URLs indexed. Google's own documentation flags soft-404 handling as an indexing-quality signal — a site with clean 4xx responses ranks better than one where every missing URL resolves to a 200-OK phantom page. The scanner probes a random URL under your origin; anything other than 404/410 is the finding.
References
Google Search Central · Soft 404 errors · RFC 9110 §15.5 (client error 4xx codes)
How to fix
Code snippet for each stack we cover. Pick the one matching your server / framework.
nginx
Use `error_page 404 /404.html;` and mark the target internal so it can't be requested directly:
error_page 404 /404.html;
location = /404.html { internal; }
Common trap: a catch-all `try_files $uri $uri/ /index.html;` for SPA frameworks makes every missing URL return the SPA shell at 200. Replace with `try_files $uri $uri/ =404;` for static routes and route SPA fallbacks only under a specific prefix (e.g. `location /app/ { try_files ... /app/index.html; }`).
apache
ErrorDocument 404 /404.html
<Files "404.html">
Require all granted
</Files>
Ensure the ErrorDocument response actually carries the 404 status — a rewrite of the form `RewriteRule . /index.html [L]` will return 200 for missing URLs; add [R=404] or route missing URLs through a script that sets `http_response_code(404)`.
cloudflare
No native Cloudflare setting for this — soft-404s originate at the origin. Options: (1) Fix at origin (preferred). (2) Cloudflare Workers can intercept missing-URL patterns and return a 404 before the origin: `return new Response('Not found', {status: 404})`. (3) A Custom Error Page with a real 404 status can be set under Rules → Error Pages, but only fires when the origin returns 5xx / 522 / 523.
wordpress
WordPress serves a real 404 for missing posts if the theme's 404.php template exists AND doesn't send its own `http_response_code(200)`. Verify with: `curl -I https://your-site/does-not-exist-abc` — should return `HTTP/1.1 404 Not Found`. Common causes of soft-404s: (a) a redirect plugin (e.g. Redirection, Rank Math redirects) sending every missing URL to the homepage — remove the 404 → / catch-all rule. (b) A page-builder theme returning 200 for the default 404 page — switch to a theme with a proper 404.php or add `status_header(404); nocache_headers();` at the top of the template.
flask
Flask returns 404 automatically for unmatched routes. If you're seeing a soft-404 it's usually a catch-all: `@app.route('/<path:p>')` that renders a template with a 200 status. Fix by returning a proper 404:
@app.errorhandler(404)
def not_found(e):
return render_template('404.html'), 404
SPA hosting: only serve the SPA shell under specific prefixes, not for every unmatched URL, so real 404s reach the errorhandler.
express
Add a 404 middleware AFTER all your routes:
app.use((req, res) => {
res.status(404).render('404');
});
Never `res.sendFile(indexHtml)` unconditionally — that's the classic SPA soft-404. Match SPA fallbacks to a specific route (`app.get('/app/*', spaHandler)`) so everything else falls through to the 404 middleware.
rails
Rails routes fall through to a 404 by default when nothing matches. Soft-404s usually come from a catch-all like `get '*path' => 'pages#show'` where the controller renders a template with default status 200. Either return the correct status:
def show
@page = Page.find_by(slug: params[:path])
return render 'errors/not_found', status: :not_found if @page.nil?
render :show
end
Or drop the catch-all and let Rails' `public/404.html` serve missing URLs.
Verify it's working
curl -I https://your-site.com/definitely-does-not-exist-abc123 — the first response line should be `HTTP/1.1 404 Not Found` (or 410). Anything else — 200, 301, 302 — is still a soft-404. Also re-run the scanner or the standalone tool at webauditfix.com/tools/soft-404 to confirm the fix.
Want to know if your site has this issue?
Run a free audit — security, GDPR, technical SEO, AEO/GEO, and WCAG accessibility.
Audit my site →