FastAPI Showcase

What the FastAPI backend unlocks in this boilerplate — OpenAPI docs, a native JSON API, ASGI middleware, and a path to MCP.

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

SurfacePathNotes
Swagger UI/docsInteractive OpenAPI explorer (FastAPI built-in)
ReDoc/redocAlternative OpenAPI viewer
OpenAPI schema/openapi.jsonMachine-readable spec
Liveness probe/healthzReturns active backend + Dash version
Active backend/api/backendBackend name, label, async flag
Page registry/api/pagesAll Dash pages, sortable list with metadata
LLM markdown/<page>/llms.txtMounted by dash-improve-my-llms 2.0 — backend-detected, identical body across Flask/FastAPI/Quart
Bot policy/robots.txtSame surface as the Flask build; same RobotsConfig
Sitemap/sitemap.xmlSame 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:

  1. Pydantic models drive the schema. BackendInfoModel, PageSummary, PageListResponse, HealthResponse — each one shows up in /docs with example values.
  2. Route ordering matters. Mount your showcase routes before add_llms_routes(app). The package's /<page>/llms.txt is 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:


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

ConcernBeforeAfter
Backend selectionhardcoded FlaskDASH_BACKEND env var
OpenAPI surfacenone/docs, /redoc, /openapi.json
Health checknone/healthz
JSON API for toolingnone/api/backend, /api/pages
Async callbacksthread-poolednative async def
Websocket callbacksnot availablectx.websocket
MCP integrationnot availablebest-effort, gated on DASH_MCP_ENABLED
LLM routesadd_llms_routes()ported in lib/asgi_routes.py (subset)
Analyticsbefore_requestStarlette 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: