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

<!-- 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:

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

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

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

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.

<!-- 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.

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: