Serve a SPA with FastAPI app.frontend() (2026)

Web Development Sep 22, 2026

Watch on YouTube

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 omit fallback for default "auto"
  • API routes always checked first; frontend never steals /api/...
  • SPA deep links (/dashboard) get index.html when the browser asks for HTML; asset 404s stay 404
  • Static build output only — not SSR

StaticFiles mount vs app.frontend() — pick deliberately

ConcernStaticFiles(..., 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) — use 404.html if present (status stays 404); else index.html for HTML navigations
  • fallback="index.html" — classic SPA client-side routing
  • fallback="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 StaticFiles at / on top of the same app if you already call app.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.

Share:
Last updated: September 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices