Baobaobaolin.com
date
entry
022
topic
infrastructure
rev

A content site needs a real 404, not an SPA fallback

In the first entry I wrote about the directory key trap: /blog/ serving the homepage with a 200. I explained the fix but not the cause — which is SPA error handling applied to a site that has no client-side routing.

An SPA routes in the browser. When someone opens /settings/profile directly there is no such file on the server, yet that URL is meaningful to the frontend. So an SPA must tell the CDN: for any path you cannot find, return index.html with a 200 — let the JavaScript load and decide what to render.

That configuration is necessary for an SPA. It is harmful on a content site, where every valid URL corresponds to a file in storage. Not found means genuinely not found.

Three costs of calling a 404 a 200

The problem is not what the user sees — they get the homepage and work out they went wrong. The problem is that the status code is a statement addressed to machines, and the machines all believe it.

  1. Search engines index URLs that do not exist. Mistyped links, retired paths, someone's typo — each returns 200 and a full copy of the homepage. Search Console calls this a soft 404, and the effect is one piece of content spread across dozens of URLs, diluting the page that actually matters.
  2. Monitoring and link checking stop working. Anything keyed on status codes — uptime checks, crawlers, a link checker in CI — reports everything healthy. This is exactly the class of failure that only content comparison detects.
  3. You deceive yourself too. A file missed during deploy, a directory key not restored, a mistyped path — all of them present as "this page looks like the homepage" rather than as an error that surfaces.

The status code is the sentence you say to machines. Lie in it and the one misled is you.

What a content site configures instead

Point the error response at a real error page and keep the status code at 404:

Error code:            404
Response page path:    /404.html
HTTP Response code:    404      ← the point; do not set 200

CloudFront's custom error responses let you substitute a different status code there, and that is exactly how an SPA turns 404 into 200. A content site wants the opposite: change the page, keep the code.

The visitor gets a page that looks like the site and says there is nothing here; crawlers and monitoring get an honest 404.

A private S3 bucket returns 403, not 404

One implementation detail defeats the rule above. When OAC makes the bucket private, the bucket policy usually grants only s3:GetObject and not s3:ListBucket. Under that combination, requesting a key that does not exist returns 403 AccessDenied rather than 404 NoSuchKey — a caller without list permission is not supposed to learn whether a key exists.

So configure both: 403 and 404 each pointing at /404.html, each returning 404.

The cost is losing "403 means the permissions are wrong" as a signal — a genuine permission failure now wears a 404. To keep that signal, the alternative is granting s3:ListBucket in the bucket policy so S3 can answer 404 honestly and 403 stays reserved for real permission problems. One extra permission for one more diagnosable failure; worth it, in my view.

Do not redirect automatically

"Send missing paths to the homepage" is the other common approach. Better than a fallback, still not good.

For the visitor, a redirect replaces the mistyped path in the address bar — they lose the chance to see what they got wrong and to fix one character and retry. For machines, a 301 or 302 claims this resource moved to the homepage, which is untrue.

Staying on the URL, returning 404, and showing a "nothing here, try the index" page is more honest on all three counts.

One command verifies it

curl -sI https://example.com/this-does-not-exist | head -1
# expect: HTTP/2 404
# HTTP/2 200 → your content site is running an SPA fallback

Worth adding to the post-deploy verification. This rule lives in CDN configuration rather than in the repo, so it can change without anyone touching code — and when it does, there is no commit to blame.

If you remember one thing

An SPA fallback exists to make non-existent paths work; a content site needs non-existent paths to look non-existent. The requirements are opposites, so the configuration cannot be shared. Before copying someone's setup, ask whether your URLs have files behind them.

Revision history

  1. First published