Skip to content

Build1 publisher2 min readPublished

FastAPI's frontend() helper matches SPA fallback only after every API route fails

A dev.to engineer ran three FastAPI apps against the same dist directory and found that a catch-all declared above /api/ping answers 200 with index.html, while the new frontend() helper answered correctly from the wrong position.

The Engineer · Build desk

What happened

  • FastAPI shipped an app.frontend() helper across versions 0.138.0 to 0.141.0, between 20 June and 29 July, meant to replace both the StaticFiles mount and the hand-written catch-all route.
  • A dev.to engineer built three apps on FastAPI 0.141.1 serving the same dist directory beside a GET /api/ping route, changing only the declaration order and the mechanism serving the frontend.
  • With the catch-all declared above the API route, curl to /api/ping returned HTTP 200 and the single-page app shell HTML instead of the JSON the route was written to produce.
  • Mounting StaticFiles at / ahead of the API broke the same path the other way, returning 404 with a {"detail":"Not Found"} body.
  • Calling router.frontend before include_router, deliberately in the wrong place, still returned {"pong":true} from /api/ping.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure A wrong-handler 200 is invisible to any check that reads status codes only, so the error surfaces in the client trying to parse an HTML document as JSON.
  • constraint The catch-all makes correctness depend on file order, so every API route added later is another chance to break the API without a log line.
  • capability Checking frontend routes last and consulting the Accept header lets a missing asset 404 for scripts while browser navigation still reaches the SPA shell.
  • decision Anyone on FastAPI below 0.138.0 now picks between an upgrade and enforcing declaration order in code review.

Starlette matches in declaration order, so the first pattern that accepts a path wins [9]. A catch-all at `/{full_path:path}` accepts everything, `/api/ping` included [3]. `frontend()` never enters that comparison. FastAPI stores frontend routes separately as `_low_priority_routes` and checks them only after every ordinary path operation has failed to match, wherever the `.frontend()` call appears in the file [12].

In the third test app the call was placed before `app.include_router()` on purpose, and `GET /api/ping` still answered `{"pong":true}` [11]. The dev.to author wrote that this is "a routing behaviour, not a coding-style convention, so it survives someone reordering the file later" [13].

The second defect in the catch-all is quieter. An unmatched GET is an unmatched GET, so the naive version returns `index.html` both to a browser navigating to a client-side route and to a script asking for an asset that is not there; a typo'd bundle path, `/assets/app-typo.js`, came back 200 with an HTML body even when the request carried `Accept: application/json` [14]. The post points out that a build step checking that its own bundle exists would pass on that response [15]. `frontend()` reads the header first. `_is_frontend_navigation_request()` looks for `text/html` or `application/xhtml+xml` [18], so the same typo'd path with `Accept: application/json` returned 404 [16], and `/some/client/route` with `Accept: text/html` still got the shell [17].

The standard advice, mount `StaticFiles` after your API routes, does neither of those things. Mounted correctly with `html=True`, `/some/client/route` 404s outright [19], which is the deep-link problem the catch-all was written to solve [2]. The catch-all in turn answers `HEAD /assets/app.js` with 405, because `@app.get` registers GET only [20]. `app.frontend()` registers `{"GET", "HEAD"}` explicitly, and so does a `StaticFiles` mount [21].

What was measured is three small apps on one machine, each serving a `dist/` directory holding one `index.html` and one `assets/app.js`, on FastAPI 0.141.1 [5]. For the 200-with-HTML case to reach a production service, the SPA and the API have to be served by the same FastAPI app with the frontend at `/`. Where a proxy sends `/api/*` to the app and everything else to static hosting, the conflicting match never happens and an upgrade changes nothing about routing.

The helper arrived across four minor release lines, 0.138 through 0.141 [2], inside a 39-day window from 20 June to 29 July [1]. Teams pinned below 0.138.0 keep the old rule, which is that the fallback route works only if it is declared after every API route [3]. The post argues that a developer adding an `/api/ping` route six months after the frontend was wired up has no way to know it has to go in before the mount [10].

What to watch

  • Whether FastAPI documents frontend() route priority publicly, given the behaviour currently rests on the internal _low_priority_routes attribute in fastapi/routing.py.
  • What _is_frontend_navigation_request() does with Accept: */*, which is neither text/html nor application/xhtml+xml.
  • Whether anyone other than this author reproduces the 200-with-HTML case on 0.141.x, including under a reverse proxy that splits /api from static paths.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories