Skip to main content

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.

8 min read
Cover showing FastAPI's new app.frontend() method mounting a built SPA directory alongside API routes

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 matching router.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 to index.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) — serves 404.html if your build output has one, otherwise falls back to index.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 actual 404 status 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 with app.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.html for unknown paths,” that reason just got a lot weaker for low-to-medium traffic deployments.

StaticFiles(html=True) hack vs. app.frontend()

ConcernStaticFiles + manual catch-allapp.frontend()
Navigation fallback to index.htmlYou write the catch-all routeBuilt in, one line
Missing asset still returns 404Only if you add extension-checking logicCorrect by default
API routes can be shadowedYes, if catch-all is registered carelesslyNo — path operations always match first
Multiple SPAs in one appManual mount juggling per prefixrouter.frontend() per router
Startup validation of build directoryNone unless you add itcheck_dir=True by default
Custom 404 page supportManualfallback="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=False removes 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 a frontend() and a StaticFiles mount) at overlapping paths, you can still end up debugging which fallback actually fired.
  • The router.routes breaking 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 walks router.routes directly — 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


Written for umesh-malik.com — no-fluff technical writing on AI, Web Dev, and Engineering.

Share this article:
X LinkedIn