Serve a SPA with FastAPI app.frontend() (2026)
Serve a SPA with FastAPI app.frontend() (2026) — the first-class API for shipping a static React, Vue, Svelte, Angular, or Astro build from the same FastAPI process. Path operations win first; the frontend is low-priority. SPA client routes fall back to index.html only for browser GET/HEAD with Accept: text/html; missing JS/CSS still return 404.
PyInns already has FastAPI + React/Vue integration, which uses CORS plus StaticFiles(directory=..., html=True). That pattern still works. This guide is the 2026 docs path: app.frontend() (FastAPI 0.138+). For shipping the same app to production, pair this with FastAPI + Docker + PostgreSQL and real-time pieces from WebSockets in FastAPI.
TL;DR
- Requires FastAPI
>=0.138(feature landed in 0.138.0, June 2026) app.frontend("/", directory="dist", fallback="index.html")— or omitfallbackfor default"auto"- API routes always checked first; frontend never steals
/api/... - SPA deep links (
/dashboard) getindex.htmlwhen the browser asks for HTML; asset 404s stay 404 - Static build output only — not SSR
StaticFiles mount vs app.frontend() — pick deliberately
| Concern | StaticFiles(..., html=True) | app.frontend() |
|---|---|---|
| API vs UI priority | Mount order is easy to get wrong; mount / last and carefully |
Path operations always win; frontend is low-priority by design |
| SPA fallback | html=True serves index.html for missing paths |
fallback="index.html" or default "auto" (prefers 404.html if present) |
| Missing assets | Often also fall through to index.html (bad for broken JS URLs) |
Missing .js / .css / images still 404 |
| Accept filter | Not HTML-aware in the same way | Fallback only for GET/HEAD with Accept: text/html (or XHTML) |
Dev missing dist/ |
Fails when mounted if dir missing | check_dir="auto" warns under fastapi dev; errors in production |
Keep the older React/Vue post for CORS, Vite env URLs, and cookie auth. Use this post when you want the official SPA-serving helper instead of mounting StaticFiles at /.
Install (pin the feature)
# FastAPI 0.138.0+ adds app.frontend() / router.frontend()
uv add "fastapi>=0.138" "uvicorn[standard]"
# or: pip install "fastapi>=0.138" "uvicorn[standard]"
Official tutorial: fastapi.tiangolo.com/tutorial/frontend/.
Minimal project layout
.
├── pyproject.toml
├── app
│ ├── __init__.py
│ └── main.py
└── dist
├── index.html
└── assets
└── app.js
Build your SPA with npm run build (Vite, etc.) into dist/. app.frontend() only serves files that already exist — it does not run SSR.
Demo — API route + SPA frontend
Create a tiny dist/ so you can run the demo without a Node toolchain:
mkdir -p dist/assets
cat > dist/index.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SPA shell</title>
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<div id="root">SPA shell — client router owns /dashboard</div>
</body>
</html>
EOF
echo "console.log('spa boot');" > dist/assets/app.js
# app/main.py
from pathlib import Path
from fastapi import FastAPI
DIST = Path(__file__).resolve().parent.parent / "dist"
app = FastAPI(title="SPA + API demo")
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
# Register AFTER path operations. Frontend is low-priority either way,
# but keeping API definitions first matches how you read the file.
app.frontend("/", directory=str(DIST), fallback="index.html")
Default fallback="auto" is usually enough: if dist/404.html exists, missing frontend paths get that file with status 404; otherwise browser navigations get index.html. Explicit fallback="index.html" is clearest for classic SPAs (React Router, Vue Router, TanStack Router).
Verify with TestClient (and curl)
# tests/test_frontend_spa.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_api_still_wins():
r = client.get("/api/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_spa_deep_link_falls_back_to_index():
r = client.get("/dashboard", headers={"Accept": "text/html"})
assert r.status_code == 200
assert "SPA shell" in r.text
assert "text/html" in r.headers.get("content-type", "")
def test_real_asset_is_served():
r = client.get("/assets/app.js")
assert r.status_code == 200
assert "spa boot" in r.text
def test_missing_js_is_404_not_index():
r = client.get("/assets/missing-chunk.js")
assert r.status_code == 404
assert "SPA shell" not in r.text
# Same checks with curl against `fastapi run` / uvicorn
curl -s http://127.0.0.1:8000/api/health
# {"status":"ok"}
curl -s -H 'Accept: text/html' http://127.0.0.1:8000/dashboard | head -n 3
# <!DOCTYPE html> ... SPA shell ...
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/assets/missing-chunk.js
# 404
Without Accept: text/html, a bare GET /dashboard from a non-browser client may not receive the SPA fallback — that is intentional. Browsers send HTML Accept headers on navigation.
fallback modes at a glance
fallback="auto"(default) — use404.htmlif present (status stays 404); elseindex.htmlfor HTML navigationsfallback="index.html"— classic SPA client-side routingfallback="404.html"— static multi-page / Astro-style missing pages (status 404)fallback=None— no fallback; missing paths are normal 404
APIRouter + prefix
You can attach a frontend to a router and include it under a prefix (admin UI, marketing shell, etc.):
from fastapi import APIRouter, FastAPI
app = FastAPI()
router = APIRouter()
router.frontend("/", directory="admin-dist", fallback="index.html")
app.include_router(router, prefix="/admin")
# UI lives under /admin/... ; app-level API routes still take precedence
@app.get("/api/health")
def health() -> dict[str, bool]:
return {"ok": True}
Middleware and dependencies on the app / router / include_router() still apply to frontend responses — useful for cookie-gated admin shells.
check_dir and fastapi dev
# During local work you often start the API before `npm run build`.
# fastapi dev sets FASTAPI_ENV=development → check_dir="auto" only warns.
app.frontend("/", directory="dist", check_dir="auto")
# Always require dist/ at import time (CI / prod):
app.frontend("/", directory="dist", check_dir=True)
# Dist created later by a separate build step after the app object exists:
app.frontend("/", directory="dist", check_dir=False)
In non-development environments, a missing directory raises when the app is created — catch empty Docker images early.
Production tips
- Build the SPA to
dist/in CI (or a Docker multi-stage stage), then copy only that folder into the runtime image - Do not mount
StaticFilesat/on top of the same app if you already callapp.frontend()— pick one approach - Keep API under a clear prefix (
/api/...) so OpenAPI, health checks, and webhooks never rely on frontend fallback - Ship hashed Vite assets under
dist/assets/; broken hashes should 404, not silently return HTML - This is static files only — Next.js / Nuxt SSR needs a Node (or other) renderer, not
app.frontend() - For the container + Postgres side of the same stack, see the Docker + PostgreSQL production setup
When to keep StaticFiles / CORS instead
Separate origin (Vite on :5173, API on :8000) still needs CORS and a frontend VITE_API_URL — that story is covered in the React/Vue integration guide. Use app.frontend() when you intentionally serve the built SPA from the FastAPI process (same host, simple deploy).
Wrap-up
app.frontend() is the 2026 first-class way to serve a static SPA from FastAPI: API-first routing, HTML-aware SPA fallback, real asset 404s, and sensible check_dir behavior for fastapi dev. Pin fastapi>=0.138, build to dist/, call app.frontend("/", directory="dist", fallback="index.html"), and verify with TestClient that /api wins, /dashboard returns the shell, and missing .js stays 404.