Back to News & Insights
Web Development September 20, 2026 · 7 min read

FastAPI's new app.frontend() fixes a route-order bug in manual SPA serving

I tested FastAPI 0.138's app.frontend() against the two patterns it replaces. A catch-all route declared before an API route returns HTTP 200 with the wrong body; app.frontend() gets the order right regardless, at the same ~440 req/s.

FastAPI's new app.frontend() fixes a route-order bug in manual SPA serving

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.html, not your JSON. No error, no 404, nothing in the logs that looks wrong. I found this while testing app.frontend(), the new call FastAPI shipped across versions 0.138.0 to 0.141.0, between 20 June and 29 July this year.

Serving a single-page app from FastAPI has meant one of two things until now. Mount StaticFiles at / and you get correct asset serving but no fallback for client-side routes, so a hard refresh on /settings/profile 404s. Or write a catch-all @app.get("/{fullpath:path}") that falls back to index.html, which fixes the deep link but only works if you remember to declare it after every API route. app.frontend() is meant to replace both. I set up FastAPI 0.141.1 locally and tried to break it.

I wrote three small apps that all serve the same dist/ directory (one index.html, one assets/app.js) alongside a GET /api/ping route, varying only the order the routes are declared in and which mechanism serves the frontend.

That's a live API route, returning a 200, with a completely different body. Nothing about that response tells you it went to the wrong handler.

Different failure, same root cause: whichever route matches / first wins, and Starlette matches in declaration order, so the person adding a /api/ping route six months after the frontend was wired up has no way to know they need to add it before the mount.

Now the same layout with router.frontend("/", directory="dist") called before app.includerouter(), deliberately in the wrong position to see if it mattered:

It didn't matter. Reading the source in fastapi/routing.py explains why: frontend routes are stored separately as lowpriorityroutes and only checked after every ordinary path operation has failed to match, regardless of where .frontend() appears in the file. That's a routing behaviour, not a coding-style convention, so it survives someone reordering the file later.

The catch-all pattern has a second problem I hadn't thought about until I tried it: it can't distinguish "the user navigated to a client-side route" from "the browser asked for an asset that doesn't exist". Both are unmatched GET requests, and the naive version returns index.html for both.

A typo'd script path returns 200 and an HTML document. If that request came from a build step checking that its own bundle exists, it would pass.

app.frontend() checks the Accept header before deciding whether to fall back. A request for the same path, same server, with an Accept: application/json header:

And a request for an unmatched path with Accept: text/html, which is what a browser sends on navigation, still gets the SPA shell:

That distinction lives in isfrontendnavigationrequest(), which looks for text/html or application/xhtml+xml in the Accept header. Plain StaticFiles(html=True), correctly mounted after the API routes this time, doesn't do either of these things by default — a request to /some/client/route 404s outright, which is the exact deep-link problem the catch-all exists to solve in the first place:

HEAD requests against the catch-all pattern fail with 405, because @app.get only registers GET:

app.frontend() registers {"GET", "HEAD"} explicitly, and so does a plain StaticFiles mount, so this is specific to the catch-all, not something the old approach always got wrong. I assumed at first that HEAD would fail the same way on the mount-based version too, and it doesn't — worth checking before you generalise a finding like this.

Protecting the frontend with auth is a bigger gap in the old pattern than I expected. app.mount() and StaticFiles() take no dependencies argument at all:

There's no built-in way to gate a static mount behind a dependency; you'd reach for ASGI middleware instead, which is a different API to learn just for this one thing. app.frontend() inherits whatever dependencies are set on its router, so wrapping it in an APIRouter(dependencies=[Depends(requiretoken)]) protects the SPA shell and every asset under it in one line, while leaving unrelated routes on the same app untouched:

| Request | Without token | With token | |---|---|---| | GET / | 401 | 200 | | GET /assets/app.js | 401 | 200 | | GET /api/ping | 200 (unaffected) | 200 |

None of this is free if it comes at a performance cost, so I benchmarked asset serving on the correctly-configured version of the old pattern (StaticFiles, mounted after the API routes) against app.frontend(), 3,000 requests at a concurrency of 20, three runs each:

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation