Put a FastAPI catch-all route above your API route by mistake and a request to /api/ping returns HTTP 200. The body is your single-page app's index...
For further actions, you may consider blocking this person and/or reporting abuse
The 200 plus index.html on /api/ping is the failure I'd lose a day to. A 404 shows up in the first curl. A JSON client seeing 200 and then choking on
<!doctypelooks like a serializer bug until someone dumps the raw body._low_priority_routesis what survives a laterinclude_routershuffle. "Put the catch-all last" dies the first time someone adds a route at the bottom of the file.That's exactly it, and it's the part that worries me most about this class of bug: a 404 fails loud, a wrong-body 200 fails quiet. You only notice once something downstream chokes on unexpected HTML, and by then you're debugging the wrong layer, the JSON parser or your own serializer, before it occurs to you to check what actually got returned.
Agreed on _low_priority_routes too. "Put the catch-all last" is a convention a human has to remember to uphold every single time someone touches the file, forever. Moving it into the routing mechanism itself means the correctness doesn't depend on anyone remembering a rule.
You both nailed the core operational danger here. A 404 Fails Loud gives you immediate visibility, but a 200 Fails Quiet returning raw HTML instead of JSON completely destroys downstream clients and wastes hours on ghost serialization bugs.
Relying on developers to manually maintain 'put the catch-all last' is a ticking time bomb in any scaling codebase. Human conventions always break during high-pressure releases or massive include_router refactoring shuffles.
Moving priority sorting directly into the routing engine itself—via mechanical solutions like _low_priority_routes—is the only way to guarantee architectural safety. It removes human error and ensures correctness by design, not by memory.
yep, the quiet 200 is the nasty one. your frontend gets index.html where it wanted JSON and you lose an afternoon blaming the serializer lol. agreed it belongs in the framework, nobody remembers "catch-all goes last" when they're shuffling routers around at 6pm on a release day
That 'blaming the serializer' loop is such a universal pain point—it completely exposes why configuration by convention fails under release pressure.
Relying on developers to maintain correct order during late-night refactoring handles is exactly how high-severity regressions slip into production. Moving that logic down into the framework substrate is the only permanent solution.
If you are structuring custom routing setups in FastAPI today before these mechanics become native, a solid defensive pattern is utilizing a custom APIRouter lifecycle hook or a custom APIRoute class. By programmatically sorting the routes list inside the FastAPI instance immediately after all endpoints are included, you can physically force any route tagged as a frontend fallback to the absolute bottom of the internal execution array, guaranteeing architectural safety by default.
The
Acceptcheck is a content negotiation signal, not proof of navigation. An API client can sendAccept: text/htmlfor an unknown path and receive the SPA shell, so doesapp.frontend()also exclude paths with a file extension?Good catch, and no, it doesn't exclude by extension. Looking at
_is_frontend_navigation_request()infastapi/routing.py, the check is purely on the Accept header,text/htmlorapplication/xhtml+xml, nothing about the path itself. So your scenario holds: a client that sends Accept:text/htmlto an unmatched path, extension or not, gets the SPA shell instead of a 404.I only tested the
Accept-headerbehavior against a known asset path with a typo, not against an arbitrary API-shaped path with a spoofed Accept:text/html. That's a real gap in the testing, not just a theoretical one. You're right that content negotiation is a hint the client controls, not proof of navigation intent, so it inherits the same silent-failure shape as the original catch-all bug, just gated behind a header instead of route order. Worth a follow-up test. Thanks for catching it.Great article! I've been working with similar API patterns and found that caching middleware can really help with performance. Have you tried combining multiple data sources for better coverage? The aggregation approach has saved us a lot of time.
Thanks! Caching definitely helps in general, though it's a bit orthogonal to what this post is about — the fix here is specifically about route registration order (making sure API routes are matched before the catch-all SPA fallback), not response caching or data aggregation. Those are worth doing too, but they wouldn't have prevented this particular bug, a request to /api/whatever would've been swallowed by the frontend fallback regardless of whether the API response was cached.
Route declaration order has always been a silent friction point in FastAPI, and having a catch-all route accidently hijack /api endpoints without throwing a formal 404 or runtime error is a painful debugging loop. Testing app.frontend() across the 0.138.0 to 0.141.0 updates provides a necessary operational sanity check for anyone serving single-page applications natively.
Your technical reproduction highlights key behavior patterns for SPA hosting:
thanks! one catch on the router tip though. an /api prefix on its own doesn't save you. if the /{full_path:path} catch-all got registered first it still matches /api/whatever, because Starlette just walks the routes in order. the prefix only helps if the catch-all skips it or comes last, which is basically what app.frontend() does for you now. +1 on the CI test, but I'd do it lazier: hit a real /api endpoint with TestClient and assert you get JSON back, not text/html. it catches the bug without caring how the routes are ordered
You are completely right on both counts—I stand corrected. If the greedy catch-all sits at the absolute root initialization step, Starlette evaluates it immediately and renders the prefix boundary useless.
That TestClient content-type assertion is a brilliant, much more elegant shortcut. Testing for the actual structural outcome (assert JSON, not HTML) rather than checking the internal router sequence is classic black-box validation. It shields the test suite from breaking if the framework changes how it registers routes under the hood.
Thanks for the sharp course correction, Alex—lazy integration testing wins again!
haha lazy testing ftw. cheers!
Cheers!