SPA frontend()・静的配信・テンプレート・マウント統合
FastAPI v0.162.2 は、新設された低優先度静的ルーティング機構 app.frontend() / router.frontend() を軸に、モダンな Single-Page Application(SPA)配信、サーバーサイド HTML テンプレート、独立した ASGI サブアプリケーション、そしてレガシー WSGI アプリの段階的移行を単一プロセス上でシームレスに統合します。
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import code, phosphor
from drawlib.lines import line, lines
from drawlib.shapes import rectangle
from drawlib.smartarts import ChevronProcess, Panel
from drawlib.styles import Styles
from drawlib.text import text
setup(width=188, height=98, dpi=150)
outer = Panel(
"FastAPI v0.162.2 — app.frontend() & router.frontend() Low-Priority Dispatch",
style=Styles.Neutral.patch(shape_r=2.0),
header_style=Styles.PrimaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=12.0),
icon=phosphor.compass,
icon_style=Styles.PrimaryBold,
header_variant="filled",
header_height=8.5,
)
outer.draw((4.0, 4.0), width=180.0, height=90.0)
# Top 4-Stage Evaluation Order inside APIRouter.app()
stages = ChevronProcess(
style=Styles.Neutral,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
corner_angle=60.0,
spacing=1.6,
flat_left_end=True,
)
stages.add("1. Normal Routes", description="APIRoute, WS & Mount", style=Styles.SecondaryNeutral)
stages.add("2. Router DI Gate", description="Depends() & Longest Path", style=Styles.PrimaryNeutral)
stages.add("3. Static Lookup", description="dist/ File & Dir Index", style=Styles.Neutral)
stages.add(
"4. Fallback Gate",
description="404.html / Accept HTML",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=10.5),
description_style=Styles.White.patch(text_size=10.0),
)
stages.draw(xy=(8.0, 70.0), width=172.0, height=12.5)
# Left Ingress Card (cx=23, cy=36.5, w=30, h=53 -> x=8..38, y=10..63)
rectangle((23.0, 36.5), width=30.0, height=53.0, style=Styles.PrimaryNeutral.patch(shape_r=2.0))
code.fastapi((23.0, 55.5), width=5.6, style=Styles.Primary)
text((23.0, 47.5), "ASGI Request", style=Styles.DarkBold.patch(text_size=11.0))
text(
(10.2, 27.5),
"• GET /api/items\n• GET /assets/app.js\n• GET /dashboard\n Accept: text/html\n• GET /missing.js\n Accept: */*",
style=Styles.Dark.patch(text_size=10.0, halign="left"),
)
# 4 Outcome Tracks (y = 56.5, 43.2, 29.9, 16.6)
tracks = [
(
56.5,
("1. self.routes Match?", "@app.get / WS / Mount", Styles.SecondaryNeutral, False),
("Execute API Route", "Never shadowed by '/'", Styles.White, False),
("200 / API JSON 404", "API 404s stay JSON", Styles.SecondaryNeutral, False),
False,
),
(
43.2,
("2. File in dist/?", "_lookup_static_resource", Styles.Neutral, False),
("Direct FileResponse", "Threadpool stat + ETag", Styles.White, False),
("200 OK Static Asset", "dist/assets/app.js", Styles.PrimaryNeutral, False),
False,
),
(
29.9,
("3. Accept: text/html?", "_is_frontend_navigation", Styles.PrimaryNeutral, False),
("SPA Shell Fallback", "Serves dist/index.html", Styles.PrimaryFlat, True),
("200 OK Client Router", "React / Vue / Svelte", Styles.PrimaryNeutral, False),
True,
),
(
16.6,
("4. Non-HTML / Missing", "Accept: */* or q=0", Styles.Neutral, False),
("Skip index.html", "Or serve dist/404.html", Styles.White, False),
("404 Not Found", "Prevents '<' JS syntax bug", Styles.Neutral, False),
False,
),
]
# Orthogonal trunk x=38 -> x=44 -> 4 tracks entering x=50 (6.0u arrow gap)
line((38.0, 36.5), (44.0, 36.5), style=Styles.DarkBold)
lines([(44.0, 16.6), (44.0, 56.5)], style=Styles.DarkBold)
for ty, c1, c2, c3, is_focal in tracks:
arr_style = Styles.PrimaryBold if is_focal else Styles.DarkBold
line((44.0, ty), (50.0, ty), arrow_head="->", style=arr_style)
# Column 1: cx=68.5, w=37 -> x=50..87
rectangle((68.5, ty), width=37.0, height=11.0, style=c1[2].patch(shape_r=1.6))
text((68.5, ty + 2.2), c1[0], style=Styles.DarkBold.patch(text_size=10.2))
text((68.5, ty - 2.4), c1[1], style=Styles.Dark.patch(text_size=10.0))
# Gap 87 -> 94 (7.0u)
line((87.0, ty), (94.0, ty), arrow_head="->", style=arr_style)
# Column 2: cx=113.5, w=39 -> x=94..133
rectangle((113.5, ty), width=39.0, height=11.0, style=c2[2].patch(shape_r=1.6))
h_st = Styles.WhiteBold if c2[3] else Styles.DarkBold
b_st = Styles.White if c2[3] else Styles.Dark
text((113.5, ty + 2.2), c2[0], style=h_st.patch(text_size=10.2))
text((113.5, ty - 2.4), c2[1], style=b_st.patch(text_size=10.0))
# Gap 133 -> 140 (7.0u)
line((133.0, ty), (140.0, ty), arrow_head="->", style=arr_style)
# Column 3: cx=160.0, w=40 -> x=140..180
rectangle((160.0, ty), width=40.0, height=11.0, style=c3[2].patch(shape_r=1.6))
text((160.0, ty + 2.2), c3[0], style=Styles.DarkBold.patch(text_size=10.2))
text((160.0, ty - 2.4), c3[1], style=Styles.Dark.patch(text_size=10.0))
save()
1. app.frontend() と router.frontend() によるネイティブ SPA 配信
従来、React・Vue・Svelte などのビルド成果物(dist/)を app.mount("/", StaticFiles(directory="dist", html=True)) でルート "/" に配信しようとすると、「先にマウントすると @app.get("/api/...") を覆い隠してしまう」「クライアントサイドルート(/dashboard/analytics 等)をブラウザでリロードすると 404 になる」という 2 つの課題がつきまとっていました。
FastAPI v0.162.2 では、app.frontend() と router.frontend() によってこれらがフレームワーク標準機能として解決されました。通常の router.routes ではなく低優先度リスト router._low_priority_routes に _FrontendRouteGroup を登録するため、アプリケーション全体(インクルードされた全 APIRouter を含む)の API ルート・WebSocket・末尾スラッシュリダイレクトがすべて不一致だった場合にのみフロントエンド探索が実行されます。
| パラメータ / 指定値 | 既定値 | _FrontendStaticFiles(fastapi/routing.py)の実行時挙動 |
|---|---|---|
fallback="auto" |
はい | directory 直下に 404.html があれば 404 で返し、なければ HTML ナビゲーション要求時に index.html(200)へフォールバック(どちらも無ければ 404) |
fallback="index.html" |
いいえ | 404.html が共存していても HTML ナビゲーション要求には常に index.html(200)を返却。check_dir 有効時は起動時に index.html の存在を検証 |
fallback="404.html" |
いいえ | 対象プレフィックス配下の未検出パスに対してカスタム静的 404.html をステータス 404 で返却。起動時にファイル存在を検証 |
fallback=None |
いいえ | フォールバックを無効化。実在するビルドファイルおよびサブディレクトリ index.html のみを配信し、未検出時は通常の JSON 404 を返却 |
check_dir="auto" |
はい | FASTAPI_ENV="development"(fastapi dev 実行時に自動設定)では警告のみで起動時チェックをスキップし、本番環境ではディレクトリ欠落時に RuntimeError で即時停止 |
グローバル優先順位・セグメント境界一致・ルーター DI 保護
自前のキャッチオール @app.get("/{full_path:path}") 実装では見落とされがちな以下の 4 つの安全設計が、_FrontendRoute と _FrontendRouteGroup に組み込まれています。
- グローバル API 優先と API
404の保全:include_router()の登録順序に関わらず通常のAPIRouteとWebSocketが常に優先されます。@app.get("/api/items/{item_id}")にマッチした上でエンドポイントがHTTPException(404)を送出した場合も、SPA のindex.htmlにすり替わることなく JSON の404応答がそのまま返ります。 Acceptヘッダー交渉によるブラウザナビゲーション判定(_is_frontend_navigation_request):index.htmlフォールバック時はAcceptヘッダーのメディアタイプと品質値(q)を厳密に解析し、text/htmlまたはapplication/xhtml+xml(q != 0)を明示的に受け入れるGET/HEAD要求のみindex.html(200 OK)を返します。存在しない JS バンドル(GET /assets/missing.js/Accept: */*)や API 誤 URL(Accept: application/json)には正しく404を返すため、<script>タグが HTML を読み込んでUnexpected token '<'で壊れる事故を防ぎます。- セグメント境界での最長プレフィックス選択:
app.frontend("/", directory="site")とrouter.frontend("/admin", directory="admin_dist")を併用した場合、パスセグメント境界で一致判定を行い(/adminが/administrationを誤捕捉しない)、最も具体的なプレフィックスを選択します。 - 保護されたフロントエンドへの依存性注入(DI)適用:
router.frontend()はアプリ階層・include_router()・APIRouter(dependencies=[Depends(...)])の依存関数を外側から内側へ順に解決します。Cookie セッション検証、カスタムResponseヘッダー付与、BackgroundTasks、テスト時のapp.dependency_overridesがそのまま機能します。
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
def build_spa_service(dist_dir: Path, admin_dist: Path) -> FastAPI:
app = FastAPI(title="Unified API + SPA Service")
@app.get("/api/items/{item_id}")
async def read_item(item_id: int) -> dict[str, object]:
if item_id == 0:
raise HTTPException(status_code=404, detail="Item not found")
return {"item_id": item_id, "source": "api"}
def require_admin_cookie(request: Request) -> None:
if request.cookies.get("__Host-admin_session") != "verified-session":
raise HTTPException(status_code=401, detail="Admin session required")
admin_router = APIRouter(dependencies=[Depends(require_admin_cookie)])
admin_router.frontend("/admin", directory=admin_dist, fallback="index.html")
app.include_router(admin_router)
# Low-priority SPA mount at "/" never shadows /api/* or /admin/*
app.frontend("/", directory=dist_dir, fallback="auto")
return app
2. SPA frontend()・テンプレート・マウント・WSGI の使い分け
1 つの FastAPI ASGI ホストプロセス上で、クライアントサイド描画(SPA)、Jinja2 によるサーバーサイド HTML 描画、サブチーム単位の独立 FastAPI アプリ、そして同期型 WSGI レガシー資産を適材適所で共存させることができます。
Source Code
from drawlib.canvas import save, setup
from drawlib.diagrams.architecture import (
ArchitectureDiagram,
CodeIcon,
Node,
NodeGroup,
PhosphorIcon,
)
from drawlib.icons import phosphor
from drawlib.lines import line, lines
from drawlib.smartarts import Panel
from drawlib.styles import Styles
setup(width=188, height=98, dpi=150)
outer = Panel(
"Unified ASGI Host Process (Uvicorn / Hypercorn · app = FastAPI())",
style=Styles.Neutral.patch(shape_r=2.0),
header_style=Styles.PrimaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=12.0),
icon=phosphor.plugs,
icon_style=Styles.PrimaryBold,
header_variant="filled",
header_height=8.5,
)
outer.draw((4.0, 4.0), width=180.0, height=90.0)
diag = ArchitectureDiagram(
node_style=Styles.PrimaryBold,
node_text_style=Styles.DarkBold.patch(text_size=10.3),
edge_style=Styles.DarkBold,
edge_text_style=Styles.Dark.patch(text_size=10.0),
node_card_style=Styles.White.patch(shape_r=1.5),
)
# Top Focal Dispatcher Node (center at y=75.0 -> bottom=68.0)
diag.add(
Node(
"Root FastAPI Router & Middleware Stack\nself.routes -> app.mount() -> _low_priority_routes",
width=86.0,
height=14.0,
icon=CodeIcon.FASTAPI,
icon_size=6.0,
card_style=Styles.PrimaryFlat.patch(shape_r=1.8),
text_style=Styles.WhiteBold.patch(text_size=10.8),
style=Styles.WhiteBold,
),
(94.0, 75.0),
)
# 4 Strategy Columns: x = 8, 52, 96, 140 (width=40, height=48, y=8..56, centers cx=28, 72, 116, 160)
g_spa = diag.add(
NodeGroup(
title="1. SPA frontend()",
width=40.0,
height=48.0,
style=Styles.PrimaryNeutral.patch(shape_r=1.8),
text_style=Styles.DarkBold.patch(halign="left", text_size=10.8),
),
(8.0, 8.0),
)
g_spa.add(
Node("dist/ Static Build\n& index.html Shell", width=34.0, height=14.0, icon=PhosphorIcon.GLOBE, icon_size=5.2),
(20.0, 32.0),
)
g_spa.add(
Node("Accept: text/html\n+ Router Depends()", width=34.0, height=14.0, icon=PhosphorIcon.SHIELD_CHECK, icon_size=5.2),
(20.0, 13.0),
)
g_ssr = diag.add(
NodeGroup(
title="2. SSR + Static",
width=40.0,
height=48.0,
style=Styles.Neutral.patch(shape_r=1.8),
text_style=Styles.DarkBold.patch(halign="left", text_size=10.8),
),
(52.0, 8.0),
)
g_ssr.add(
Node("Jinja2Templates\nTemplateResponse", width=34.0, height=14.0, icon=PhosphorIcon.CODE, icon_size=5.2),
(20.0, 32.0),
)
g_ssr.add(
Node("/static Mount\nurl_for('static')", width=34.0, height=14.0, icon=PhosphorIcon.FILE_DOC, icon_size=5.2, card_style=Styles.SecondaryNeutral),
(20.0, 13.0),
)
g_sub = diag.add(
NodeGroup(
title="3. Sub-App Mount",
width=40.0,
height=48.0,
style=Styles.SecondaryNeutral.patch(shape_r=1.8),
text_style=Styles.DarkBold.patch(halign="left", text_size=10.8),
),
(96.0, 8.0),
)
g_sub.add(
Node("subapi = FastAPI()\nroot_path='/subapi'", width=34.0, height=14.0, icon=CodeIcon.FASTAPI, icon_size=5.2),
(20.0, 32.0),
)
g_sub.add(
Node("Isolated OpenAPI\n/subapi/docs", width=34.0, height=14.0, icon=CodeIcon.OPENAPI, icon_size=5.2, card_style=Styles.PrimaryNeutral),
(20.0, 13.0),
)
g_wsgi = diag.add(
NodeGroup(
title="4. Legacy WSGI",
width=40.0,
height=48.0,
style=Styles.Neutral.patch(shape_r=1.8),
text_style=Styles.DarkBold.patch(halign="left", text_size=10.8),
),
(140.0, 8.0),
)
g_wsgi.add(
Node("a2wsgi Bridge\nWSGIMiddleware", width=34.0, height=14.0, icon=PhosphorIcon.LIGHTNING, icon_size=5.2),
(20.0, 32.0),
)
g_wsgi.add(
Node("Flask / Django\nWorker Threadpool", width=34.0, height=14.0, icon=CodeIcon.PYTHON, icon_size=5.2, card_style=Styles.SecondaryNeutral),
(20.0, 13.0),
)
diag.draw()
# Orthogonal fan-out bus from Root Node bottom (y=68.0) -> bus (y=62.5) -> group tops (y=56.0, 6.3u terminal gap)
line((94.0, 68.0), (94.0, 62.5), style=Styles.DarkBold)
lines([(28.0, 62.5), (160.0, 62.5)], style=Styles.DarkBold)
for cx in (28.0, 72.0, 116.0, 160.0):
line((cx, 62.5), (cx, 56.2), arrow_head="->", style=Styles.PrimaryBold if cx == 28.0 else Styles.DarkBold)
save()
| 統合メカニズム | 登録 API | 優先順位とスコープ分離 | 最適なユースケース |
|---|---|---|---|
| SPA 静的ビルド配信 | app.frontend("/", directory="dist") |
最低優先度(_low_priority_routes)・Depends() 対応 |
/api と同居する Vite / React / Vue / Svelte / 静的サイト |
| 名前付き静的マウント | app.mount("/static", StaticFiles(...), name="static") |
self.routes 内のプレフィックス Mount・url_for 対応 |
Jinja2 や自己配信 Swagger UI から参照する CSS / JS / 画像 |
| サーバーサイド描画 | Jinja2Templates(directory="templates") |
通常の APIRoute(response_class=HTMLResponse) |
HTMX フラグメント、メール本文生成、SEO 重視の SSR ページ |
| 独立サブアプリ | app.mount("/subapi", subapi) |
独立した FastAPI インスタンス(root_path="/subapi"・専用 /docs) |
マルチバージョン公開 API(/v2)、パートナー向け API、管理画面 |
| レガシー WSGI 統合 | app.mount("/v1", WSGIMiddleware(flask_app)) |
a2wsgi により同期 WSGI をワーカースレッド上で実行 |
Flask / Django モノリスから FastAPI への段階的マイグレーション |
3. Jinja2Templates と StaticFiles によるサーバーサイド HTML 描画
サーバーサイドレンダリング(SSR)や HTMX ベースの UI を構築する場合は、jinja2 パッケージを導入し、fastapi.templating.Jinja2Templates と名前付き StaticFiles マウントを組み合わせます。
- キーワード引数形式の
TemplateResponseシグネチャ: 現行の Starlette / FastAPI では必ずrequest=request, name="item.html", context={...}のキーワード引数形式で呼び出します(旧来の位置引数TemplateResponse("item.html", {"request": request})は非推奨化されています)。 url_forによる逆引きアセット URL 生成: マウント時にname="static"を指定すると、テンプレート内から{{ url_for('static', path='/styles.css') }}でroot_path対応の URL を安全に生成できます。- XSS 自動エスケープと属性クォートの徹底: Jinja2 は
.html/.htm/.xmlに対して既定でautoescapeを有効化します。HTML 属性に変数を埋め込む際は必ず二重引用符で囲み(data-item-id="{{ id }}")、外部入力に対する安易な| safeフィルタの適用は避けてください。
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Path as PathParam, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
def create_ssr_app(static_dir: Path, templates_dir: Path) -> FastAPI:
app = FastAPI()
app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=templates_dir)
# templates/item.html:
# <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
# <h1 data-item-id="{{ id }}">Item: {{ title }}</h1>
@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item_page(
request: Request,
id: Annotated[str, PathParam(pattern=r"^[a-zA-Z0-9_-]+$")],
title: str = "Wireless Keyboard",
) -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="item.html",
context={"id": id, "title": title},
)
return app
4. 独立サブアプリケーションと a2wsgi によるレガシー WSGI 移行
独立サブアプリと ASGI root_path の自動伝播
ルート定義を単一の OpenAPI スキーマと共通ミドルウェア層へ統合する app.include_router() とは対照的に、app.mount("/subapi", subapi) は 完全に独立した別の ASGI アプリケーション を指定パス配下に接続します。
- OpenAPI スキーマと Docs UI の完全分離: 親アプリは親ルートのみを含む
/openapi.jsonと/docsを公開し、subapiは独自の/subapi/openapi.jsonと/subapi/docsを独立して提供します。 - ASGI
root_pathの自動設定: Starlette のMountがsubapi呼び出し時にscope["root_path"]へ"/subapi"を自動付与するため、/subapi/docsの Swagger UI はスキーマ取得先や「Try it out」の送信先 URL を自動的に/subapi/...基準で解決します。 lifespan実行に関する注意点: マウントされたサブアプリのlifespanイベントは Starlette のMountからは自動実行されません。共有リソースの初期化は親アプリのlifespanに集約するか、contextlib.AsyncExitStackでサブアプリの lifespan を明示的に連結してください(詳細は 大規模 DI・Lifespan 管理・テスト戦略 を参照)。
a2wsgi.WSGIMiddleware による段階的 WSGI 移行(ストラングラーパターン)
既存の Flask や Django モノリスを一括書き換えせずに段階的に FastAPI へ移行する場合、レガシー WSGI アプリを特定プレフィックスにマウントし、新規エンドポイントから順に外側の FastAPI 側で非同期実装していくストラングラーパターン(Strangler Fig Pattern)が有効です。Starlette 旧内蔵の starlette.middleware.wsgi.WSGIMiddleware は非推奨化されているため、FastAPI 公式ドキュメントでは a2wsgi.WSGIMiddleware(pip install a2wsgi)の利用が推奨されています。a2wsgi は非同期イベントループと同期 WSGI ワーカースレッド間のストリーム変換を安全に仲介し、マウントパスを SCRIPT_NAME として自動伝播します。
from a2wsgi import WSGIMiddleware
from fastapi import FastAPI, Request
from flask import Flask, jsonify, request as flask_request
# 1. Independent FastAPI sub-application with isolated /subapi/docs
subapi = FastAPI(title="Partner Sub-API", version="2.0.0")
@subapi.get("/sub")
def read_sub(request: Request) -> dict[str, str]:
return {
"message": "Hello from sub-application",
"root_path": request.scope.get("root_path", ""),
}
# 2. Legacy synchronous Flask (WSGI) application
flask_app = Flask(__name__)
@flask_app.get("/orders")
def legacy_orders():
return jsonify({"service": "legacy-flask", "script_name": flask_request.script_root})
# 3. Unified FastAPI host mounting both the sub-app and WSGI bridge
app = FastAPI(title="Main Gateway API", version="1.0.0")
@app.get("/app")
def read_main() -> dict[str, str]:
return {"message": "Hello World from main app"}
app.mount("/subapi", subapi)
app.mount("/v1", WSGIMiddleware(flask_app))
次節 Webhooks・Callbacks・SDK 生成・Docs UI では、OpenAPI 3.1.0 を活用した非同期イベント仕様の公開、クリーンなクライアント SDK 自動生成、および閉域網向け Swagger UI / ReDoc 自己配信パターンを解説します。リバースプロキシ配下での --forwarded-allow-ips 運用については FastAPI CLI・プロキシ・GraphQL・AI 連携 もあわせて参照してください。