Zero API routes reachable. That was the state of my dashboard the moment the static directory actually existed on disk.
I was building a dashboard on top of FastAPI. The frontend was a SPA built to a dist folder. The backend exposed endpoints under /api. Standard setup. To serve the SPA, I reached for the obvious thing:
app.mount("/", StaticFiles(directory="dist", html=True), name="static")
Works fine in development when dist is empty or missing. StaticFiles quietly does nothing. The API routes serve normally. Everything looks great.
Ship it. Now dist exists with actual files in it. Every single /api request returns index.html instead of hitting the router. No error. No 404. Just silently wrong responses.
The issue is how Starlette's routing priority works. Mounts are matched before routes when the mount path is /. StaticFiles with html=True serves index.html for any path it does not recognize instead of falling through. Combined, this means the mount intercepts every request before your actual route handlers ever see it.
The fix is two moves.
Mount only /assets, not /:
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
Then add a catch all route at the end of your router that serves index.html:
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
return FileResponse("dist/index.html")
Route handlers registered before the catch all always win. /api/whatever hits the API. /settings or /dashboard or any frontend path falls through to the catch all and gets index.html. Clean separation, explicit priority.
The tradeoff worth knowing: you lose automatic handling for static files inside dist that are not in /assets. Fonts, favicon, manifest.json often live at the dist root. You either move them into dist/assets, add additional explicit mounts per path, or handle them in the catch all before returning index.html. In my case everything was already under /assets, so it was not an issue.
What I would do differently: never mount StaticFiles at /. The failure mode is surprising because it is silent (no error, no 404, just the wrong response) and only manifests once the directory is populated. The fix html=True provides (serving index.html for unknown paths) is exactly what you want to control explicitly anyway. Mount at a specific path, serve index.html via a real route handler. The extra ten lines make the routing contract obvious to anyone reading the code later.
Top comments (0)