FastAPI Showcase
What the FastAPI backend unlocks in this boilerplate — OpenAPI docs, a native JSON API, ASGI middleware, and a path to MCP.
Why a dedicated showcase?
Most Dash docs assume Flask. With Dash 4.1+ you can swap in a real FastAPI app under the hood, and once you do, things you used to bolt on with extra packages — OpenAPI docs, native async, websocket callbacks, structured JSON APIs — become first-class.
This boilerplate ships both backends side by side. Switch with one env var:
# .env
DASH_BACKEND=fastapi
When the badge in the header reads FastAPI · async, the surface described below is live.
What lights up on FastAPI
| Surface | Path | Notes |
|---|---|---|
| Swagger UI | /docs | Interactive OpenAPI explorer (FastAPI built-in) |
| ReDoc | /redoc | Alternative OpenAPI viewer |
| OpenAPI schema | /openapi.json | Machine-readable spec |
| Liveness probe | /healthz | Returns active backend + Dash version |
| Active backend | /api/backend | Backend name, label, async flag |
| Page registry | /api/pages | All Dash pages, sortable list with metadata |
| LLM markdown | /<page>/llms.txt | Mounted by dash-improve-my-llms 2.0 — backend-detected, identical body across Flask/FastAPI/Quart |
| Bot policy | /robots.txt | Same surface as the Flask build; same RobotsConfig |
| Sitemap | /sitemap.xml | Same priority inference; respects mark_hidden() |
Everything in the Surface column appears in /docs because the routes are declared with typed Pydantic models and response_model=... — Swagger UI picks them up automatically.
Try it live
This widget hits the JSON endpoints directly from your browser and pretty-prints the response. Useful for confirming the surface is up before you point a real client at it.
:code: false
The OpenAPI link in the header
The header surfaces a Swagger badge only when the active backend is FastAPI. The logic is small:
# components/header.py
def _create_openapi_link():
info = get_backend_info()
if info.name != "fastapi":
return None
return dmc.Anchor(
dmc.Badge("OpenAPI", leftSection=DashIconify(icon="logos:swagger"), ...),
href="/docs",
target="_blank",
)
Open it in a new tab and you can Try it out against any of the routes without leaving the app.
How the routes are mounted
run.py calls add_llms_routes(app) unconditionally — dash-improve-my-llms 2.0 detects the FastAPI backend and mounts its own router. The boilerplate's lib/asgi_routes.py only carries the showcase surfaces (/healthz, /api/backend, /api/pages) — the things that demonstrate first-class OpenAPI integration:
# File: lib/asgi_routes.py
"""
FastAPI showcase routes for the documentation boilerplate.
The AI/LLM surfaces (``/llms.txt``, ``/<page>/llms.txt``, ``/robots.txt``,
``/sitemap.xml``) are mounted by ``dash-improve-my-llms`` 2.0 directly —
the package detects the FastAPI backend and registers its own router.
This module only carries the **showcase** surfaces that demonstrate
first-class OpenAPI integration under Dash 4.1+'s FastAPI backend:
- ``/healthz`` — liveness probe
- ``/api/backend`` — active backend info
- ``/api/pages`` — registered Dash pages, sortable list
These show up in Swagger UI at ``/docs`` and ReDoc at ``/redoc`` because
each route declares a Pydantic ``response_model``.
"""
from __future__ import annotations
from typing import List, Optional
import dash
from fastapi import APIRouter, FastAPI
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Pydantic models — these power the OpenAPI schema at /docs
# ---------------------------------------------------------------------------
class BackendInfoModel(BaseModel):
name: str = Field(..., description="Active backend identifier")
label: str = Field(..., description="Human-readable backend label")
is_async: bool = Field(..., description="True for ASGI backends (fastapi, quart)")
description: str
class PageSummary(BaseModel):
name: str
path: str
title: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
class PageListResponse(BaseModel):
backend: str
count: int
pages: List[PageSummary]
class HealthResponse(BaseModel):
ok: bool = True
backend: str
dash_version: str
# ---------------------------------------------------------------------------
# Router factories
# ---------------------------------------------------------------------------
def build_api_router(app, backend_info) -> APIRouter:
"""Native FastAPI showcase routes — populate /docs and /redoc."""
router = APIRouter(prefix="/api", tags=["showcase"])
@router.get("/backend", response_model=BackendInfoModel, summary="Active backend")
def get_backend() -> BackendInfoModel:
return BackendInfoModel(
name=backend_info.name,
label=backend_info.label,
is_async=backend_info.is_async,
description=backend_info.description,
)
@router.get(
"/pages",
response_model=PageListResponse,
summary="Registered Dash pages",
)
def list_pages() -> PageListResponse:
pages: List[PageSummary] = []
for p in dash.page_registry.values():
pages.append(PageSummary(
name=p.get("name"),
path=p.get("path"),
title=p.get("title"),
description=p.get("description"),
icon=p.get("icon"),
))
return PageListResponse(
backend=backend_info.name,
count=len(pages),
pages=sorted(pages, key=lambda x: x.path),
)
return router
def build_health_router() -> APIRouter:
router = APIRouter(tags=["health"])
@router.get("/healthz", response_model=HealthResponse, summary="Liveness probe")
def healthz() -> HealthResponse:
return HealthResponse(
ok=True,
backend="fastapi",
dash_version=dash.__version__,
)
return router
def register_asgi_routes(app, backend_info) -> None:
"""Mount the showcase FastAPI routers on ``app.server``.
These must be registered **before** ``add_llms_routes(app)`` so that
the package's catch-all ``/<page>/llms.txt`` matcher does not shadow
``/healthz`` or ``/api/*``.
"""
server: FastAPI = app.server # type: ignore[assignment]
server.include_router(build_health_router())
server.include_router(build_api_router(app, backend_info))
Two things to notice:
- Pydantic models drive the schema.
BackendInfoModel,PageSummary,PageListResponse,HealthResponse— each one shows up in/docswith example values. - Route ordering matters. Mount your showcase routes before
add_llms_routes(app). The package's/<page>/llms.txtis greedy and would otherwise shadow/api/*matches.
run.py calls register_asgi_routes(app, BACKEND_INFO) in the elif BACKEND == "fastapi": branch and that's the whole wire-up.
Analytics as ASGI middleware
The Flask build uses @server.before_request for the visitor tracker. Under FastAPI, that hook doesn't exist — instead we mount a Starlette middleware:
# File: lib/asgi_middleware.py
"""
ASGI/Starlette middleware ports of Flask-only hooks used in this boilerplate.
When the Dash backend is FastAPI, these slot in where the Flask
``before_request`` decorator was used.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from lib.analytics_tracker import tracker
class AnalyticsMiddleware(BaseHTTPMiddleware):
"""Track every request through the analytics tracker.
Mirrors the Flask ``before_request`` shim in ``run.py``. Failures are
silently swallowed — analytics should never block a real response.
"""
async def dispatch(self, request: Request, call_next) -> Response:
try:
client = request.client
ip = client.host if client else None
# Headers carry the real client IP/country behind a proxy or CDN;
# request.client is the last hop (the proxy) in production.
tracker.track_visit(
request.url.path,
request.headers.get("user-agent", ""),
ip,
headers=dict(request.headers),
)
except Exception:
pass
return await call_next(request)
def register_asgi_middleware(app) -> None:
"""Attach all ASGI middleware to ``app.server`` (a FastAPI instance)."""
app.server.add_middleware(AnalyticsMiddleware)
This sits transparently in front of every request, including Dash's internal _dash-update-component calls, with no impact on callback latency (analytics writes go through the same backgroundable tracker).
Async callbacks (you can write them now)
Once you're on FastAPI, callbacks can be async def and await directly. A typical pattern:
import httpx
from dash import Input, Output, callback
@callback(Output("price", "children"), Input("ticker", "value"))
async def fetch_price(ticker):
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"https://api.example.com/{ticker}")
return r.json()["price"]
The key discipline: don't mix sync I/O into async callbacks. Replace requests with httpx.AsyncClient, psycopg2 with asyncpg, time.sleep with asyncio.sleep. A single blocking call inside an async def will stall every other request handled by that worker.
If you can't avoid a blocking call, wrap it:
import asyncio
@callback(Output("data", "children"), Input("trigger", "n_clicks"))
async def slow_path(_):
return await asyncio.to_thread(legacy_sync_fetch)
See the difference
This widget runs two callbacks side by side: a sync one that sleeps 3 × 500 ms sequentially (~1500 ms wall-clock) and an async one that fans out the same work with asyncio.gather (~500 ms). Both work on any backend, but the async path is what scales when concurrent users hit your app.
:code: false
Stress test it
The button below fires N parallel GET /healthz requests from your browser and reports wall-clock + percentile latencies. On the FastAPI backend the wall-clock stays flat as N rises (the event loop services them concurrently). On Flask's default sync workers, wall-clock grows roughly linearly with N — that's the practical cost of WSGI's one-request-per-thread model.
:code: false
Websocket callbacks (Dash 4.2+)
ASGI gives Dash native websocket support. From a callback you can grab the websocket directly via ctx.websocket and push updates to the browser without polling.
from dash import Input, Output, callback, ctx
@callback(Output("stream", "children"), Input("start", "n_clicks"))
async def stream(_):
ws = ctx.websocket
for i in range(10):
await ws.send({"i": i})
return "done"
This is gated behind the FastAPI backend — the Flask backend will simply not expose ctx.websocket.
MCP server (Dash 4.3+)
When Dash 4.3 ships, the same FastAPI app can expose the dashboard's layout, components, pages, and (whitelisted) callbacks as MCP tools over Streamable HTTP. The wire-up in this boilerplate is best-effort:
# run.py — runs only if the installed Dash supports it
try:
from dash import mcp_enabled
HAS_MCP = True
except ImportError:
HAS_MCP = False
if HAS_MCP and BACKEND == "fastapi" and os.environ.get("DASH_MCP_ENABLED") == "1":
mcp_enabled(app)
Set DASH_MCP_ENABLED=1 in .env once you upgrade to a Dash version that includes MCP, and the server will be reachable at /mcp (subject to whatever path Dash settles on for the GA release).
What's missing on FastAPI today
Honesty matters for a docs site:
set_propsworks on FastAPI but if you scale to multiple workers you'll need Redis pub/sub for cross-worker fanout (same caveat as Flask).- The
dash-improve-my-llmsMCP bridge silently no-ops on Dash 3.x and on Dash 4.1/4.2 — it requires Dash 4.3+ (wheredash.mcplands). The HTTP surfaces (/llms.txt,/robots.txt,/sitemap.xml) work on every version.
Deployment
Swap the process manager:
# Flask (Dockerfile default today)
gunicorn run:server -b 0.0.0.0:8550
# FastAPI
pip install "dash[fastapi]" "uvicorn[standard]"
uvicorn run:server --host 0.0.0.0 --port 8550 \
--workers 4 --proxy-headers --forwarded-allow-ips='*'
The app.server attribute returns either a Flask or FastAPI instance depending on DASH_BACKEND, so run:server works for both — only the runner changes.
Recap
| Concern | Before | After |
|---|---|---|
| Backend selection | hardcoded Flask | DASH_BACKEND env var |
| OpenAPI surface | none | /docs, /redoc, /openapi.json |
| Health check | none | /healthz |
| JSON API for tooling | none | /api/backend, /api/pages |
| Async callbacks | thread-pooled | native async def |
| Websocket callbacks | not available | ctx.websocket |
| MCP integration | not available | best-effort, gated on DASH_MCP_ENABLED |
| LLM routes | add_llms_routes() | ported in lib/asgi_routes.py (subset) |
| Analytics | before_request | Starlette middleware |
Flip the env var, restart, watch the badge change. That's the whole user surface — everything else is a clean import switch under the hood.
Source: /fastapi-showcase
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /fastapi-showcase/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt