FastAPI Finally Has Native SPA Support: app.frontend() Explained
FastAPI 0.138.0 ships app.frontend() — a native way to serve React, Vue, and Svelte SPA builds. How it works, real use cases, and what it still can't do.

On June 20, 2026, FastAPI shipped v0.138.0, and buried in a changelog full of typo fixes and translation updates is one feature that closes a nine-year-old gap: app.frontend(). It’s a first-class, official way to serve a built single-page app — React, Vue, Svelte, Astro, whatever — directly from your FastAPI process, with correct client-side-routing fallback baked in. No more hand-rolled catch-all routes. No more StaticFiles(html=True) workarounds that almost-but-not-quite handle SPA routing.
This isn’t a rumor or a roadmap item — it’s merged, documented, and live in the official docs right now. Here’s exactly what it does, where it earns its place in a real deployment, and where it still leaves you on your own.
TL;DR
- FastAPI 0.138.0 (2026-06-20) adds
app.frontend(path, directory="dist")and the matchingrouter.frontend()— a dedicated API for serving static SPA build output (PR #15800). - It replaces the community-standard hack of mounting
StaticFiles(html=True)plus a manual catch-all route, and it gets one subtle thing right that hand-rolled versions usually don’t: missing assets still 404, only missing pages fall back toindex.html. - Your API routes always win — frontend fallback is only checked after no
@app.get(...)route matches, so it can’t accidentally swallow a real/api/*404. - It explicitly does not do server-side rendering. It serves a directory your frontend’s build step already produced — nothing more.
- It ships six days after a router-internals refactor (0.137.0) that’s the real engineering story underneath this feature — and that refactor has its own breaking change worth knowing about if you’re upgrading.
What app.frontend() Actually Does
Before this release, serving a Vite/React build from FastAPI looked like this:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
from starlette.requests import Request
app = FastAPI()
@app.get("/api/health")
def health():
return {"status": "ok"}
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
@app.get("/{full_path:path}")
async def spa_fallback(request: Request, full_path: str):
return FileResponse("dist/index.html") That catch-all route is the part everyone gets subtly wrong. It matches everything, including requests for files that genuinely don’t exist — /assets/app-x7f3.js with a typo’d hash returns index.html with a 200, not a 404, and the browser tries to parse HTML as JavaScript. You can patch around it (check os.path.exists, branch on file extension), but now you’re maintaining frontend-serving logic by hand.
The new way is the verbatim example from FastAPI’s own docs:
from fastapi import FastAPI
app = FastAPI()
app.frontend("/", directory="dist") One line. Mount your existing @app.get routes above or below it — order doesn’t matter, because FastAPI checks path operations first, every time, and only falls through to the frontend directory when nothing else matched.
How It Works
The full signature is app.frontend(path, directory="dist", fallback="auto", check_dir=True), and every parameter maps to a real decision you’d otherwise be encoding yourself.
Client-side routing, done correctly
This is the headline behavior: FastAPI distinguishes a request for a missing asset (.js, .css, an image) from a request that looks like browser navigation (/dashboard/settings, /users/42). The former still 404s normally. The latter falls back to index.html so your client-side router — React Router, Vue Router, SvelteKit’s own routing, whatever — gets a chance to render the right view.
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
return {"id": user_id}
app.frontend("/", directory="dist", fallback="index.html") A request to /api/users/999 that doesn’t exist still hits your handler and returns whatever 404 logic you wrote there. A request to /dashboard/settings — a route your SPA owns, not FastAPI — falls back to index.html, and the SPA’s router takes it from there.
The four fallback modes
"auto"(default) — serves404.htmlif your build output has one, otherwise falls back toindex.html, otherwise returns a normal 404."index.html"— always fall back to the SPA shell for unmatched navigation requests. The standard SPA setting."404.html"— serve a custom 404 page with an actual404status code, instead of silently rendering the SPA shell for dead links.None— disable fallback entirely; unmatched paths just 404. Useful if you’re serving a plain static site, not a router-driven SPA.
check_dir
By default (check_dir=True), FastAPI validates the directory exists when you call app.frontend() — so a typo’d path fails loudly at startup instead of silently 404ing every request in production. Set check_dir=False only when your build step runs after the FastAPI() app object is created (common in some CI/build pipelines), and be aware you’re trading that startup safety net for flexibility.
Mounting under APIRouter
router.frontend() works identically, which means you can serve a frontend under a prefix, or run multiple frontends side by side:
from fastapi import APIRouter, FastAPI
app = FastAPI()
admin_router = APIRouter()
admin_router.frontend("/", directory="admin-dist", fallback="index.html")
app.include_router(admin_router, prefix="/admin")
app.frontend("/", directory="dist", fallback="index.html") Two completely independent SPA builds, one FastAPI process, zero extra static-file plumbing.
The Refactor This Sits On
app.frontend() didn’t appear in isolation. Six days earlier, v0.137.0 (2026-06-14) shipped a structural change to how APIRouter and APIRoute work internally: routers used to flatten and “clone” every path operation when you called include_router(), so the final app only ever had one big flat list of routes. That release rebuilt this into a preserved tree — router_a.include_router(router_b) now keeps the actual router_b object alive instead of copying its routes — which the FastAPI maintainer described as unblocking dependencies-per-router, middleware-per-router, and custom route-matching behavior down the line. app.frontend() is the first user-visible feature built on top of that tree.
WHY app.frontend() IS WORTH ADOPTING
The value isn't a new capability — StaticFiles could already serve a SPA with enough glue code. The value is that FastAPI now owns the correctness of the fiddly parts.
CORRECTNESS
Assets 404, pages fall back — automatically
The distinction between a missing JS file and a missing client-side route is exactly the part hand-rolled catch-all routes usually get wrong.
- No more 200 responses for typo'd asset URLs
- No manual extension-sniffing logic to maintain
SAFETY
API routes can never be shadowed
Path operations are matched first, unconditionally. The frontend mount is only ever a fallback, not a competing route.
- No route-ordering footguns
- Works the same whether frontend() is called first or last
SIMPLICITY
One line replaces a small file
StaticFiles + a manual catch-all + extension checks is maybe 15-20 lines. This is one method call.
- Less code to review
- Less code that can silently regress
COMPOSABILITY
Multiple frontends, one process
router.frontend() plus include_router(prefix=...) gives you isolated SPA mounts without separate static-file servers.
- Admin SPA + public SPA in one deploy
- Each gets its own fallback mode
Use Cases
- Single-container deploys. Build your React/Vite or Vue app, copy
dist/next to your FastAPI app, mount it withapp.frontend(), and ship one Docker image instead of a separate nginx-or-CDN layer just to serve static files. For small apps and internal tools this collapses a whole piece of infrastructure. - Internal admin tools and dashboards. Teams that build a small internal SPA on top of an internal API rarely want a dedicated frontend hosting pipeline. One FastAPI process serving both the API and the dashboard is now a one-liner instead of a maintenance burden.
- Multi-tenant or multi-app backends.
router.frontend()under different prefixes lets one FastAPI service host a public marketing SPA at/and an admin SPA at/admin, each with its own fallback behavior, without spinning up separate services. - Replacing nginx-as-static-server in simple setups. If your only reason for an nginx sidecar was “serve the SPA’s
index.htmlfor unknown paths,” that reason just got a lot weaker for low-to-medium traffic deployments.
StaticFiles(html=True) hack vs. app.frontend()
| Concern | StaticFiles + manual catch-all | app.frontend() |
|---|---|---|
| Navigation fallback to index.html | You write the catch-all route | Built in, one line |
| Missing asset still returns 404 | Only if you add extension-checking logic | Correct by default |
| API routes can be shadowed | Yes, if catch-all is registered carelessly | No — path operations always match first |
| Multiple SPAs in one app | Manual mount juggling per prefix | router.frontend() per router |
| Startup validation of build directory | None unless you add it | check_dir=True by default |
| Custom 404 page support | Manual | fallback="404.html" built in |
Issues FastAPI Still Has Here
This is a genuinely useful feature, and it’s also genuinely young — merged six days before this post was written. The official docs are honest about the biggest limitation, in a section literally titled “Static Build Output Only”: app.frontend() serves files your frontend’s build step already produced. It does not run server-side rendering, and it isn’t a fit for frameworks that need per-request rendering on the server.
Beyond that documented boundary, a few things are worth knowing before you reach for it in production:
- No dev-server integration. This serves a build, not a live dev server. There’s no proxying to a running Vite/webpack dev server with HMR — you still run your frontend’s own dev server locally and point it at the FastAPI API separately, same as before this feature existed.
- No caching or cache-busting story. Nothing in the docs covers
Cache-Control, ETags, or immutable caching for hashed asset filenames. You still need a reverse proxy or CDN in front of production traffic if you care about this —app.frontend()solves routing correctness, not HTTP caching semantics. check_dir=Falseremoves your safety net silently. It’s the right call when your build runs after app creation, but it means a misconfigured path now fails at request time in production instead of at startup, where it’s far cheaper to catch.- Ordering footguns still exist, just a different kind. API routes always win over the frontend mount, which is good — but if you mount two
frontend()calls (or afrontend()and aStaticFilesmount) at overlapping paths, you can still end up debugging which fallback actually fired. - The
router.routesbreaking change rides along in the same upgrade. If you’re jumping from a pre-0.137 version straight to 0.138.x to get this feature, audit for any code that walksrouter.routesdirectly — it’s an internal tree now, not a flat list. - No production track record yet. This is days-old API surface from a fast-moving project. The maintainer’s PR notes claim full test coverage and no measured performance regression, which is a reasonable bar — but “reasonable bar” and “battle-tested in production for a year” are different claims. Treat it like any brand-new dependency feature: try it on something low-stakes first.
SHOULD YOU USE app.frontend() TODAY
Track progress as you work through the list
0%
0/5 done
FAQ
Questions readers usually have
Common questions about FastAPI's app.frontend() feature.
Try It Yourself
Everything described above — app.frontend(), a second SPA mounted with router.frontend() under /admin, API routes that take precedence, and the asset-vs-navigation 404 distinction — is in a runnable example: examples/fastapi-spa-app-frontend in this site’s repo. It’s a minimal FastAPI backend plus a Vite/React SPA with three client-side routes, and the README walks through building the frontend and running the backend so you can curl each of the behaviors above yourself.
Final Take
app.frontend() isn’t a flashy feature, and it doesn’t need to be. It takes a pattern thousands of FastAPI projects have hand-rolled — usually slightly wrong, in the specific way of returning 200 for missing assets — and makes the correct version a one-liner. The more interesting story is underneath it: a router-internals refactor from six days earlier that quietly unlocked this and several other features still to come, with its own breaking change for anyone iterating router.routes directly.
If you’re shipping a SPA behind FastAPI today, this is worth trying on a side project before you trust it in production. It solves real, specific pain — and it’s honest in its own docs about exactly where it stops.
Sources
- FastAPI docs: Frontend
- FastAPI release notes — v0.138.0 and v0.137.0 entries
- PR #15800 — Add app.frontend() / router.frontend()
- PR #15745 — Refactor internals to preserve APIRouter and APIRoute instances
- Runnable example:
examples/fastapi-spa-app-frontend
Written for umesh-malik.com — no-fluff technical writing on AI, Web Dev, and Engineering.
About the Author
Software engineer writing about AI, Claude Code, LLMs, OpenAI, Anthropic, and developer tooling. 5+ years building production systems at Expedia Group, Tekion, and BYJU'S.
Related Articles

Web Engineering
Node.js Backend for Frontend Developers: Express, REST APIs, Auth, and Deployment (Step-by-Step)
A frontend developer's guide to building backend services with Node.js. Covers Express, REST APIs, middleware, database basics, authentication, and deployment — with the mindset shift from frontend to backend.

Web Engineering
The $1,100 Framework That Just Made Vercel's $3 Billion Moat Obsolete
One engineer + Claude AI rebuilt Next.js in 7 days for $1,100. The result: 4.4x faster builds, 57% smaller bundles, already powering CIO.gov in production. This is the moment AI-built infrastructure became real—and everything about software development just changed.

Web Engineering
Node.js Just Cut Its Memory in Half — One Docker Line, Zero Code Changes, $300K Saved
V8 pointer compression finally comes to Node.js after 6 years. A single Docker image swap drops heap memory by 50%, improves P99 latency by 7%, and can save companies $80K-$300K/year. Cloudflare, Igalia, and Platformatic collaborated to make it happen. Here is the full technical breakdown, real production benchmarks on AWS EKS, and why your CFO needs to see this.