# FastAPI Showcase

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

**Site index:** [https://boilerplate.2plot.dev/llms.txt](https://boilerplate.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://boilerplate.2plot.dev/sitemap.xml  


---



### 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
# .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` — 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.


<!-- component rendered from docs/fastapi-showcase/endpoint_explorer.py; source withheld by `: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:

```python
# 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 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:


```python
# 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, Request
from pydantic import BaseModel, ConfigDict, 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):
    """The probe contract, identical on every backend.

    ``lib.health.health_payload`` is the single source — this model only
    types it for Swagger. It used to be built independently here, which
    meant a FastAPI deployment silently lacked ``build``: cd.yml's
    build-match wait polls /healthz for exactly that field, so it would
    have fallen into the "predates the build field" warning path forever,
    verifying whichever release happened to be serving — the muicharts
    defect the wait was written to prevent, reintroduced per-backend
    (found on llms-2plot-dev, 2026-08-23).
    """

    # ADDITIVE KEYS ARE KEPT (1.6.44 item 20, and the reason is item 1).
    # A pydantic response_model DROPS every field it does not declare, in
    # silence. That is how `llms_version` — added to health_payload two days
    # ago as item 1's rider, and specifically so the CI-vs-production gap
    # would stop being self-reported — was present on the Flask lane and
    # ABSENT on this one, on a template whose production runs Flask so
    # nothing said so. The two ASGI forks in the fleet would have shipped a
    # healthz with no llms_version and no way to notice.
    #
    # Two defences, because either alone has failed here: the known keys are
    # declared below (so Swagger documents them), and `extra="allow"` keeps
    # anything health_payload adds NEXT. A model that silently narrows the
    # payload is the "two lanes are different documents" trap wearing a
    # type annotation.
    model_config = ConfigDict(extra="allow")

    ok: bool = True
    backend: str
    dash_version: str
    # The serving interpreter (platform.python_version()) — required, not
    # Optional: every process has one, and an absent field is exactly the
    # invisibility that let the image/matrix/render.yaml Pythons drift
    # apart (ops-seat finding, 2026-08-25).
    python: str
    # Optional because they are environment-dependent, not backend-dependent.
    build: Optional[str] = None
    app: Optional[str] = None
    geo: Optional[dict] = None
    # The resolved dash-improve-my-llms version (item 1's rider) and the
    # ledger block (item 20). Optional because health_payload omits
    # llms_version when the import itself fails, which is the finding.
    llms_version: Optional[str] = None
    ledger: Optional[dict] = None


# ---------------------------------------------------------------------------
# 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"])

    # response_model_exclude_none: THE MIRROR IMAGE of the drop this model
    # was declared to prevent (muischeduler, note 189; measured on this tree
    # 2026-09-12). `health_payload` OMITS a key it has no value for —
    # `build` when RENDER_GIT_COMMIT is unset — but an `Optional[...] = None`
    # field is SERVED AS null, so the flask lane omitted the key and this
    # lane added it: nine keys here, eight there, the lanes disagreeing about
    # the KEY SET whenever the env var is absent. Invisible in production
    # (Render sets the commit) and invisible to any test reading VALUES.
    #
    # Safe here because `health_payload` never intentionally returns null —
    # measured: zero None values anywhere in the payload, nested included.
    # It omits instead. So "null" always means "the builder withheld this",
    # and dropping those fields makes the wire match the builder exactly.
    @router.get("/healthz", response_model=HealthResponse,
                response_model_exclude_none=True, summary="Liveness probe")
    def healthz(request: Request) -> HealthResponse:
        # One payload builder for all three backends — see HealthResponse.
        # Built per request: `geo` reports live state, and this route is
        # mounted long before any geo configuration runs. The request's
        # headers go with it — geo's `resolved` reads the country header
        # from THIS request, and the Flask-context fallback inside
        # health_payload can never see a Starlette request (pannellum's
        # production healthz answered "no request context" until this).
        from lib.health import health_payload

        return HealthResponse(**health_payload("fastapi", headers=request.headers))

    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:


```python
# 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


# ``HeadAsGetMiddleware`` LIVED HERE FROM 1.6.32 TO 1.6.44, AND IS RETIRED.
#
# Why it existed: FastAPI's ``APIRoute`` takes ``methods`` literally, so a
# route declared ``@router.get(...)`` answered 405 to HEAD — and Dash's page
# catch-all is an ``APIRoute`` too, registered from the ASGI lifespan
# startup, which nothing in this repo could declare methods on. Every route
# of both FastAPI forks 405'd on HEAD (measured on pannellum and
# muischeduler, 2026-08-27), ``/healthz`` included, which is the default
# probe method of most uptime monitors.
#
# Why it is gone: at dash-improve-my-llms 2.9.4 the package walks the
# router itself and adds HEAD wherever GET is allowed, INCLUDING Dash's
# routes and its lifespan-registered catch-all — the one case this
# middleware was left in for. Its own docstring said "DO NOT REMOVE THIS
# WHEN THE PACKAGE FLOOR REACHES 2.7.2", and that was true AT 2.7.2, when
# the package fixed only its own doc routes. It stopped being true at
# 2.9.4, which is why the retirement is gated on the pin (item 1) and not
# on the calendar.
#
# The reason to remove rather than leave harmless: it converts HEAD to GET
# ABOVE the router, so every HEAD looked correct whatever the router did.
# It MASKED the package's fix instead of conflicting with it, and would
# have masked a regression in it just as well.
#
# MEASURED BEFORE REMOVING (1.6.44, dimll 2.9.4, FastAPI lane, in-process):
# 5 paths x 3 UAs — /healthz, /llms.txt, /robots.txt, /sitemap.xml, / with
# browser, crawler and internal-probe UAs — HEAD status == GET status in
# all 15 pairs WITHOUT this middleware, including ``/`` to a browser UA,
# the exact case the old docstring said would 405. The disable was proved
# non-vacuous first: the middleware stack was asserted to contain
# ``HeadAsGetMiddleware`` in the enabled run and not to contain it in the
# disabled one, because a parity result from a run that never removed
# anything would have been worthless. tests/test_head_method.py holds the
# parity so this cannot regress silently.


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)


class StaticCacheMiddleware(BaseHTTPMiddleware):
    """Give ``/assets/`` a cache lifetime (1.6.44 item 6g).

    The ASGI half of the Flask ``after_request`` in ``run.py``; the policy
    itself lives in ``lib/static_cache`` so the two lanes cannot drift into
    serving different lifetimes for the same file.
    """

    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        from lib.static_cache import cache_control_for

        value = cache_control_for(request.url.path)
        if value and response.status_code == 200:
            response.headers["Cache-Control"] = value
        return response


class CrawlerNotFoundMiddleware(BaseHTTPMiddleware):
    """A path with no page behind it answers 404 to a crawler (item 8).

    The ASGI half of the Flask `before_request` in `run.py`; the decision
    itself lives in `lib/soft404` so the two lanes cannot drift into
    answering differently for the same URL.
    """

    def __init__(self, app, dash_app=None):
        super().__init__(app)
        self._dash_app = dash_app

    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            from lib.soft404 import (NOT_FOUND_BODY, NOT_FOUND_TYPE,
                                     should_answer_404)

            if self._dash_app is not None and should_answer_404(
                    self._dash_app, request.url.path,
                    request.headers.get("user-agent", "")):
                from starlette.responses import Response as _Response

                return _Response(NOT_FOUND_BODY, status_code=404,
                                 media_type=NOT_FOUND_TYPE)
        except Exception:
            pass        # never let this turn a served page into an error
        return await call_next(request)


def register_asgi_middleware(app) -> None:
    """Attach all ASGI middleware to ``app.server`` (a FastAPI instance).

    ``HeadAsGetMiddleware`` was removed here at 1.6.44 (item 2) once the
    package answers HEAD at the route level — see the module docstring for
    what was measured before removing it.
    """
    app.server.add_middleware(AnalyticsMiddleware)
    app.server.add_middleware(StaticCacheMiddleware)
    app.server.add_middleware(CrawlerNotFoundMiddleware, dash_app=app)
```


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:

```python
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:

```python
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.


<!-- component rendered from docs/fastapi-showcase/async_demo.py; source withheld by `: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.


<!-- component rendered from docs/fastapi-showcase/stress_test.py; source withheld by `: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.

```python
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:

```python
# 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_props`** works 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-llms` MCP bridge silently no-ops on Dash 3.x and on Dash 4.1/4.2 — it requires Dash 4.3+ (where `dash.mcp` lands). The HTTP surfaces (`/llms.txt`, `/robots.txt`, `/sitemap.xml`) work on every version.

---

### Deployment

Swap the process manager:

```bash
# 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*
