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("/{full_path: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.
The order bug, reproduced
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.
With the catch-all declared first:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8002/api/ping
200
$ curl -s http://127.0.0.1:8002/api/ping
<!doctype html><html><head><title>demo</title></head><body><h1>spa shell</h1></body></html>
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.
With StaticFiles mounted at / first instead of a catch-all function:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8003/api/ping
404
$ curl -s http://127.0.0.1:8003/api/ping
{"detail":"Not Found"}
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.include_router(), deliberately in the wrong position to see if it mattered:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8004/api/ping
200
$ curl -s http://127.0.0.1:8004/api/ping
{"pong":true}
It didn't matter. Reading the source in fastapi/routing.py explains why: frontend routes are stored separately as _low_priority_routes 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 404 that a catch-all can't tell from a page
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.
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: application/json' \
http://127.0.0.1:8001/assets/app-typo.js
200
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:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: application/json' \
http://127.0.0.1:8004/assets/app-typo.js
404
And a request for an unmatched path with Accept: text/html, which is what a browser sends on navigation, still gets the SPA shell:
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Accept: text/html' \
http://127.0.0.1:8004/some/client/route
200
That distinction lives in _is_frontend_navigation_request(), 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:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8005/some/client/route
404
Two smaller gaps
HEAD requests against the catch-all pattern fail with 405, because @app.get only registers GET:
$ curl -sI http://127.0.0.1:8001/assets/app.js | head -1
HTTP/1.1 405 Method Not Allowed
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:
$ python3 -c "
from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount('/', StaticFiles(directory='dist'), dependencies=[Depends(lambda: None)])
"
TypeError: Starlette.mount() got an unexpected keyword argument 'dependencies'
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(require_token)]) 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 |
The number that argues against a clean win
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:
| run 1 | run 2 | run 3 | |
|---|---|---|---|
| StaticFiles mount | 442 req/s | 445 req/s | 441 req/s |
| app.frontend() | 440 req/s | 430 req/s | 449 req/s |
That's noise, not a trend — the two are within about 2% of each other across all six runs, and the p50 latency (~26ms on this single-worker dev server) matched in every pair. I also compared an app with no frontend registered at all against one that had app.frontend() wired up, to see whether just having the low-priority route group present slows down ordinary API dispatch:
| baseline (no frontend) | with app.frontend() | |
|---|---|---|
| run 1 | 457 req/s | 462 req/s |
| run 2 | 443 req/s | 455 req/s |
| run 3 | 460 req/s | 460 req/s |
Also no measurable difference. I went in expecting the "check every ordinary route first, then fall through to low-priority routes" design to cost something on the API path, and it doesn't show up at this request volume. I'd want a much bigger route table before trusting that this holds at scale, but at the size of a normal service it's a wash.
What I got wrong on the way
My first attempt at the auth test didn't actually call Mount() with a dependencies keyword — I'd written the "old" auth app to just mount StaticFiles plainly and assumed the absence of a dependency error meant the test had failed to run. It hadn't; I'd just forgotten to pass the argument I meant to test. Once I added dependencies=[Depends(...)] to the mount() call directly, the TypeError in the second finding above showed up immediately. It's a reminder that a passing test and a test that never exercised the thing you meant to check look identical until you read the code you wrote.
I also assumed the HEAD gap would apply to both old patterns, since they're both "the old way" in the loose sense. It doesn't — the mount-based version handles HEAD fine, because StaticFiles always has. Only the hand-written catch-all misses it, since a bare @app.get doesn't imply HEAD support the way app.frontend() and StaticFiles both do.
What I didn't get to
check_dir="auto" behaves exactly as documented: it raises RuntimeError at startup if the frontend directory is missing and FASTAPI_ENV isn't "development", and only warns if it is. Path traversal attempts (../../../etc/passwd, URL-encoded variants) 404 on both the old and new approach, which is inherited from StaticFiles in either case and isn't new behaviour worth a full section. I didn't test this against a production ASGI setup with multiple Uvicorn workers, only the single-worker dev server, so the throughput numbers above are a comparison between two configurations, not an absolute figure for what either can do.
Run it yourself
This needs fastapi[standard]==0.141.1 and httpx in a virtualenv, plus a dist/index.html and dist/assets/app.js to serve:
python3 -m venv venv && ./venv/bin/pip install 'fastapi[standard]==0.141.1' httpx
mkdir -p dist/assets
echo '<!doctype html><h1>spa shell</h1>' > dist/index.html
echo 'console.log("hi")' > dist/assets/app.js
The order-bug reproduction, trimmed to the two cases that matter:
# server.py
import sys
from fastapi import APIRouter, FastAPI
MODE = sys.argv[1]
app = FastAPI()
if MODE == "old_wrong_order":
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
from fastapi.responses import FileResponse
return FileResponse("dist/index.html")
@app.get("/api/ping")
def ping():
return {"pong": True}
elif MODE == "new_any_order":
router = APIRouter()
router.frontend("/", directory="dist")
app.include_router(router)
@app.get("/api/ping")
def ping():
return {"pong": True}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(sys.argv[2]))
Run both and compare:
./venv/bin/python server.py old_wrong_order 8002 &
./venv/bin/python server.py new_any_order 8004 &
sleep 1
curl -s http://127.0.0.1:8002/api/ping # HTML, not JSON
curl -s http://127.0.0.1:8004/api/ping # {"pong":true}
I ran this exact pair of commands to produce the output quoted above.
What to do with this
If you're already serving a SPA from FastAPI with a hand-rolled catch-all, check whether it's declared before or after your newest API routes — the failure mode is silent, so grep for {full_path:path} and read the file top to bottom rather than trusting that it's still in the right place. If you're starting a new project on FastAPI 0.138 or later, app.frontend() removes the ordering constraint entirely and gets HEAD and Accept-aware fallback for free, and there's no measured throughput cost for switching. If you need to put the frontend behind auth, that's the one case where the old pattern doesn't have a clean answer at all, and it's worth moving to app.frontend() for that alone.
Top comments (1)
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?