Skip to content

fix: serve SPA fallback with 200 instead of 404 - #218

Closed
matheuslealpa wants to merge 2 commits into
cupcakearmy:mainfrom
matheuslealpa:fix/spa-fallback-status-code
Closed

matheuslealpa wants to merge 2 commits into
cupcakearmy:mainfrom
matheuslealpa:fix/spa-fallback-status-code

Conversation

@matheuslealpa

Copy link
Copy Markdown

Summary

Fixes #217.

The SPA fallback served index.html with a 404 Not Found status. Any client
side route — /note/<id>, /about — returned the correct document, but with
the wrong status code.

Cause

ServeDir::not_found_service() is not just "serve this file when nothing
matches". It wraps the fallback in SetStatus and forces every response to 404:

pub fn not_found_service<F2>(self, new_fallback: F2) -> ServeDir<SetStatus<F2>> {
    self.fallback(SetStatus::new(new_fallback, StatusCode::NOT_FOUND))
}

The sibling method ServeDir::fallback() does the same routing but leaves the
status alone — as documented upstream: "The status code returned by the
fallback will not be altered."

Change

One method swap in packages/backend/src/main.rs:

let serve_dir =
    ServeDir::new(config::FRONTEND_PATH.to_string()).fallback(ServeFile::new(index));

A note that genuinely does not exist is still reported as 404 by
/api/notes/<id>, which is where that signal belongs.

Why it matters

Behind a reverse proxy this made effectively 100% of document requests show up
as 4xx, which skews error rate dashboards and can trigger false alerts. It also
logged a 404 (Not Found) in the browser console on every page load.

Tests

Added test/web/spa-fallback.spec.ts, asserting /, /about and
/note/<unknown> return 200 while /api/notes/<unknown> still returns 404.

Verified the test actually catches the bug rather than passing vacuously — with
the fix reverted and the image rebuilt, the /about and /note/<unknown> cases
fail with Expected: 200 / Received: 404; with the fix they pass.

Path before after
/ 200 200
/about 404 200
/note/<unknown> 404 200
/_app/version.json 200 200
/api/status 200 200
/api/notes/<unknown> 404 404

Reproduced the original behaviour against the published
cupcakearmy/cryptgeon:2.9.3 image before changing anything.

Trade-off worth flagging

With fallback(), a genuinely missing static asset (e.g.
/_app/immutable/does-not-exist.js) now returns 200 with index.html instead
of 404. This is the standard SPA fallback behaviour — the same as nginx's
try_files $uri /index.html — and is the direct consequence of serving client
side routes with 200, since the server cannot tell the two apart by path alone.

If you would rather keep 404 for missing assets, the fallback can be gated on
the request's Accept header (text/html → index.html, otherwise 404). Happy
to push that instead — it is a bit more machinery, so I went with the simpler
change first.

matheuslealpa and others added 2 commits September 3, 2026 16:09
`ServeDir::not_found_service` wraps the fallback in `SetStatus`, which forces
every response to `404 Not Found`. Client side routes such as `/note/<id>` and
`/about` were therefore served the correct `index.html` but with a 404 status.

Use `ServeDir::fallback` instead, which leaves the status untouched. A note that
genuinely does not exist is still reported as 404 by `/api/notes/<id>`.

Behind a reverse proxy this made effectively every document request show up as a
4xx, skewing error rate dashboards and triggering false alerts.

Fixes cupcakearmy#217
@cupcakearmy

Copy link
Copy Markdown
Owner

Thanks for this, superseeded by #221

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2.9.3] SPA fallback returns HTTP 404 instead of 200 for client-side routes

2 participants