2026-09-01
serve -s makes every URL on a multi-page site come back 200
Every URL returned 200. That was the problem.
Ask a static site for a page that doesn't exist and you expect a 404. On a multi-page static site I looked at recently, you got a 200, and the homepage came back instead. Every genuinely dead link, every typo, every URL that had never existed on the site, all answered the same way: success, here's the homepage. That's worse than a normal 404, not better. A 404 is honest. It tells you, and every crawler, that the URL is broken. A 200 with the wrong page underneath tells everyone the URL is fine, then quietly hands over content that has nothing to do with it.
Why the fallback exists, and why it goes wrong
The tool behind this was serve, run with the -s flag: serve -s dist. That flag turns on SPA mode. Any request path that doesn't match a real file on disk gets index.html back, with a 200 status, instead of a 404. For a genuine single-page app that's correct. All the routing happens client-side in JavaScript, so the server has to hand back the same shell for every path and let the app work out what to render from the URL. For a multi-page static site, where every route is meant to be its own real file, the same fallback stops being 'route unknown, hand it to the client' and becomes 'route unknown, pretend it's the homepage.'
I saw this on a static multi-page site I had just deployed. It had run serve -s dist as its Dockerfile CMD since launch. Every new product page looked fine in a deploy check, a plain 200, but the actual bytes coming back were always the old homepage. A URL for a page that had never existed returned 200 too. That's very likely the single best explanation for why the site had never produced a sale or any organic traffic: none of its real pages had ever actually been reachable to a crawler, or to anyone following a direct link. Google treats every URL on a site resolving to the same content as a site-wide soft 404, which is the expected consequence of this setup; I haven't separately confirmed indexing status for that site, only the response behaviour itself.
How to catch it in one request
You do not need log analysis for this. Pick a path you know cannot exist and ask for it directly.
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/this-page-should-not-exist-2026A 404 means the site is behaving correctly for a URL that should not resolve. A 200 means you have the bug, and the body of that response is almost certainly the homepage rather than an actual not-found page. Comparing response sizes across a few real, different routes exposes the same fault even faster: if two pages that are supposed to be different come back with an identical Content-Length, one of them is not the page it claims to be. A plain per-route 200 check will not catch this on its own, since every broken route also returns 200. You have to check what is actually in the body, or check a route you know cannot exist, which is exactly how this survived undetected for as long as it did.
Fixing it in serve
If you're using serve to ship a genuine multi-page static export, drop the -s flag. Without it, serve resolves each path against the files that actually exist on disk and returns a real 404 for anything that doesn't, which is the whole point. The tradeoff is that serve gives you no control over response headers, security headers included, which is what pushed the fix on the site above toward nginx instead.
# before, in the Dockerfile
CMD ["serve", "-s", "dist"]
# after
CMD ["serve", "dist", "-l", "3000"]Fixing it properly with nginx
The fix used on that site was to switch the runtime image to nginx, with a try_files rule that checks for a real file, then a real .html file, then a real directory index, and only fails to a genuine 404 if none of those exist. That restores real directory routing and real 404s, and nginx can set security headers on the response at the same time, which serve cannot do at all.
location / {
try_files $uri $uri.html $uri/index.html $uri/ =404;
}The part I would rather leave out
While writing this post I checked this site's own nginx config, on the theory that a site telling other people to check theirs had better have checked its own. It had the same class of bug. selfhostguide.dev's nginx.conf ended its main location block with a fallback to /index.html, the SPA pattern, on a site that is not a single-page app. This is what it said:
location / {
try_files $uri $uri/index.html /index.html;
}The situation wasn't identical to the case above, and I don't want to flatten the difference. Every real page on this site exists as its own prerendered file, so a genuine article or guide URL is served from disk directly by the try_files rule before it ever reaches the /index.html fallback. What still returned 200 was anything that wasn't a real route: a typo, an old link, a URL that was never a page. Those got the homepage back with a 200 instead of a 404, which is the same soft-404 shape, just on junk traffic rather than on real content.
I had written that off as a considered trade-off, on the grounds that every real route exists on disk. That reasoning was backwards. Precisely because every route is a real file, the fallback caught nothing except junk, so it bought nothing and cost indexing. It is fixed in the same change that produced this post:
location / {
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;