Serving a built frontend from FastAPI

FastAPI gains app.frontend("/", directory="dist"), which serves a compiled frontend directory from routes the router consults only after every path operation has failed to match. Most of the design sits in one question: what should the server do with a path that matches no file?

fastapi/fastapi PR 15800 at 5bd3221f, explained 2026-09-13

Background

New to ASGI routing? Expand for the deeper background.

A FastAPI application is an ASGI application: a callable that receives a scope describing the request, plus receive and send channels. Routing means picking which piece of code handles a given scope.

Routing means picking which piece of code handles a given scope. Starlette, which FastAPI builds on, gives every route a matches(scope) method, and the router asks each route in turn. The main text below defines the three answers that method can give, because the rest of the page depends on them.

Starlette also ships StaticFiles, an ASGI application that maps a URL path onto a file inside a directory. It is what actually reads bytes off disk, and the change subclasses it rather than replacing it.

A modern frontend build produces a directory of static files. Running npm run build with Vite, Astro, Angular or SvelteKit leaves something like this on disk:

dist/
index.htmlthe shell every client-side route boots from
assets/
app.js
app.css

Two pieces of FastAPI vocabulary carry the rest of this page. A path operation is a route you declare with a decorator such as @app.get("/items"). Every route, whatever its kind, answers matches(scope) with one of three verdicts: Match.FULL means it handles the request, Match.PARTIAL means the path fit but the HTTP method did not, which is how a 405 arises rather than a 404, and Match.NONE means no. The router walks its routes in order, takes the first FULL, and keeps the first PARTIAL as a consolation prize.

Before this change, serving a build directory from FastAPI meant app.mount("/", StaticFiles(directory="dist", html=True)). A mount grafts a sub-application onto a path prefix and claims that whole prefix, so mounting at the root puts the static files ahead of every path operation declared after it, and the API starts returning HTML. Ordering the mount last avoids that, which makes correctness depend on where in the file the line sits.

Key concept

A single-page app does its own routing in the browser. The URL /dashboard/settings is real to the user but corresponds to no file on disk. For a direct visit or a refresh to work, the server has to answer that URL with index.html and let the frontend framework take over. This is usually called a deep link.

That requirement collides with a second one. A request for /assets/typo.js also corresponds to no file, and answering it with index.html is actively harmful: the browser receives HTML where it expected JavaScript, and the failure surfaces as a syntax error rather than a missing file. So the server needs to answer two superficially identical requests differently.

The names this page uses, all introduced by the change unless noted: frontend() is the new public method on both FastAPI and APIRouter. _FrontendRouteGroup holds every frontend mount belonging to one router. _FrontendRoute is one such mount. _FrontendStaticFiles is the subclass of Starlette's StaticFiles that does the serving. _is_frontend_navigation_request is the predicate that separates the two cases above. _low_priority_routes is the list that holds routes the router checks last.

Intuition

I read the change as two separable mechanisms, though the commits do not frame it that way. The first settles when the frontend gets a say. The second settles what it says when the path matches no file. Neither one refers to the other in the code.

Decision one: a second tier, consulted last

Rather than appending frontend routes to the router's normal routes list, the change gives the router a second list, _low_priority_routes. The normal loop runs to exhaustion first. Only when it produces nothing, and only after the trailing-slash redirect pass has also produced nothing, does the router consult the second list.

flowchart TD
    A["Request reaches APIRouter.app"] --> B{"A normal route matches FULL?"}
    B -->|yes| C["The path operation answers"]
    B -->|no| D{"A normal route matches PARTIAL?"}
    D -->|yes| E["That route answers, usually 405"]
    D -->|no| F{"redirect_slashes finds a route?"}
    F -->|yes| G["Redirect to the slashed path"]
    F -->|no| H{"A low priority route matches?"}
    H -->|yes| I["The frontend group answers"]
    H -->|no| J["Default 404"]
    classDef touched fill:#e8eefc,stroke:#3b6cf6,color:#16181b
    classDef ok fill:#e4f3ea,stroke:#1a7f47,color:#16181b
    classDef bad fill:#fbe7e9,stroke:#c62a3b,color:#16181b
    class H,I touched
    class C ok
    class J bad

The consequence is that registration order stops deciding who wins between a frontend and a path operation. A frontend registered at / in line one cannot shadow a path operation declared in line fifty. The tests pin the awkward version of this: a router carrying a frontend is included before the API router and still loses, at tests/test_frontend.py:513. Order still decides between two frontends that tie on specificity, which section 4 comes back to.

Two details of the ordering are easy to miss. A partial match also wins, so POST to a path where a GET path operation exists returns 405 even when a servable file sits at that same path (tests/test_frontend.py:213). And the trailing-slash redirect runs before the frontend, so a request that could redirect into a real path operation does so rather than falling through (tests/test_frontend.py:577).

Decision two: guessing what a missing path meant

Once the request reaches the frontend and no file matches, the code has to choose between the deep link and the mistyped asset. It uses two signals, in this order: whether the final path segment carries a file extension, and what the client put in its Accept header.

Deciding a missing path

Missing path
/users/jane.doe
Extension check
no dot
Accept check
text/html
Serve the shell

I ran the predicate directly against the source at this commit rather than reasoning about it. These are its actual return values:

Requested path Accept header Navigation?
/dashboard/settings text/html,...,*/*;q=0.8 yes
/dashboard/settings */* yes
/dashboard/settings application/json no
/dashboard/settings text/html;q=0 no
/dashboard/settings text/html;q=0.1 yes
/dashboard/settings text/html;q=wat yes
/assets/app.js text/html,*/* no
/users/jane.doe text/html no

Two rows deserve attention. A malformed quality value is more permissive than a valid one: q=wat serves the shell while q=0 does not. Section 7 shows the parser line that produces this. And a path whose last segment carries a file extension is refused whatever the client said it accepts, which is what keeps a missing .js file out of the shell.

Edge case

The extension rule means /users/jane.doe never receives the shell. Any browser URL whose last segment looks like a filename, so a username, a domain or a version string, will fail to deep link. The behavior is pinned at tests/test_frontend.py:365, which tells us it is expected, not whether the cost was weighed. The test is os.path.splitext, not the presence of a dot, so a leading-dot segment slips through: I ran /settings/.env against the predicate and it counts as navigation.

Code walkthrough

The flow, end to end: calling app.frontend() puts a frontend route into a low-priority list; a request then falls through the normal route loop, reaches the group holding those routes, picks the most specific one, strips its prefix, and lands in a StaticFiles subclass that either finds a file or walks a fallback ladder.

1. Registration

FastAPI.frontend at fastapi/applications.py:1222 is a pass-through to the router. The real work is on APIRouter:

fastapi/routing.py:2504-2514

normalized_path = _normalize_frontend_path(path)
if self._frontend_routes is None:
    self._frontend_routes = _FrontendRouteGroup()
    self._low_priority_routes.append(self._frontend_routes)
self._frontend_routes.add_frontend_route(
    _join_frontend_paths(self.prefix, normalized_path),
    directory=directory,
    fallback=fallback,
    check_dir=check_dir,
)
self._mark_routes_changed()

One group per router, created on first use and appended to _low_priority_routes exactly once. Every later frontend() call adds a route to the existing group rather than a second entry in the list. The router's own prefix is folded into the path here, at registration time.

2. Surviving include_router

A prefix can also arrive later, through app.include_router(router, prefix="/app"), which is the form the tutorial added by this change uses (docs/en/docs/tutorial/frontend.md). That path runs through _build_effective_context, the helper that rewrites a router's routes for the place it was included:

fastapi/routing.py:1581-1585

if isinstance(route, _FrontendRouteGroup):
    return _EffectiveRouteContext(
        original_route=route,
        starlette_route=route.with_prefix(self.include_context.prefix),
    )

with_prefix copies the group and rewrites each route's path rather than mutating it, which leaves the original router unchanged and so includable more than once. The sibling method effective_low_priority_routes at fastapi/routing.py:1551 recurses into nested included routers, so a frontend inside a router inside another router still collects every prefix, pinned at tests/test_frontend.py:534.

3. The request falls through

fastapi/routing.py:2558-2578

(
    low_priority_match,
    low_priority_scope,
    low_priority_route,
    low_priority_context,
) = self._match_low_priority(scope)
if low_priority_match != Match.NONE and low_priority_route is not None:
    _update_scope(scope, low_priority_scope)
    # elided: the branch that restores an APIRoute's effective context
    await low_priority_route.handle(scope, receive, send)
    return

await self.default(scope, receive, send)

This sits at the end of APIRouter.app, after the normal loop and after the redirect pass. self.default is the plain 404, so the frontend is the last thing tried before giving up.

4. The group picks one frontend route

With two frontends registered, one at / and one at /admin, a request for /admin/settings matches both. _FrontendRouteGroup._match at fastapi/routing.py:2027 breaks the tie by specificity:

fastapi/routing.py:1791-1794

def _frontend_path_specificity(path: str) -> int:
    if path == "/":
        return 0
    return len(path)

Raw path length, with the root special-cased to zero. The comparison in _match is a strict >, so on an exact tie the first registered route keeps the win. I confirmed that by registering two directories on the same path and requesting a missing file: the first directory answered. Longest path beats registration order otherwise, pinned at tests/test_frontend.py:484.

5. The frontend route strips its own prefix

fastapi/routing.py:1977-1985

def _get_frontend_path(self, route_path: str) -> str | None:
    if self.path == "/":
        return route_path.lstrip("/")
    if route_path == self.path:
        return ""
    prefix = self.path + "/"
    if route_path.startswith(prefix):
        return route_path[len(prefix) :]
    return None

This method lives on _FrontendRoute. Building the local prefix variable with a trailing slash is what stops a frontend at /app from swallowing /application, pinned at tests/test_frontend.py:473. The stripped remainder does not travel as an argument: matches writes it into the scope, and _FrontendStaticFiles.get_path at fastapi/routing.py:1837 reads it back out.

6. The fallback ladder

_FrontendStaticFiles.get_response at fastapi/routing.py:1842 tries a real file, then a directory index, then the fallbacks:

flowchart TD
    A["get_response receives the stripped path"] --> B{"Method is GET or HEAD?"}
    B -->|no| C["405"]
    B -->|yes| D{"The path is a real file?"}
    D -->|yes| E["200 with that file"]
    D -->|no| F{"The path is a directory holding index.html?"}
    F -->|yes| G["200 with that index.html"]
    F -->|no| H{"404.html applies?"}
    H -->|yes| I["404 with 404.html"]
    H -->|no| J{"index.html applies and the request looks like navigation?"}
    J -->|yes| K["200 with index.html"]
    J -->|no| L["404 from the API"]
    classDef touched fill:#e8eefc,stroke:#3b6cf6,color:#16181b
    classDef ok fill:#e4f3ea,stroke:#1a7f47,color:#16181b
    classDef bad fill:#fbe7e9,stroke:#c62a3b,color:#16181b
    class H,J touched
    class E,K ok
    class C,L bad

fastapi/routing.py:1872-1883

if self.fallback == "404.html" or (
    self.fallback == "auto" and self._fallback_file_exists("404.html")
):
    return await self._fallback_response("404.html", scope, status_code=404)

if (
    self.fallback == "index.html"
    or (self.fallback == "auto" and self._fallback_file_exists("index.html"))
) and _is_frontend_navigation_request(scope):
    return await self._fallback_response("index.html", scope, status_code=200)

raise HTTPException(status_code=404)

The two rungs differ in two ways. The highlighted clause is the first: the 404.html branch carries no such guard, so it answers every missing path including asset requests, while the index.html branch answers only navigation. The second is the status code, 404 against 200. Rung order is a third consequence: when a build ships both files under the default fallback="auto", declared at fastapi/routing.py:2462, 404.html wins and deep linking stops working with no error anywhere (tests/test_frontend.py:391).

7. The navigation predicate

fastapi/routing.py:1921-1939

def _is_frontend_navigation_request(scope: Scope) -> bool:
    route_path = get_route_path(scope)
    final_segment = route_path.rsplit("/", 1)[-1]
    if os.path.splitext(final_segment)[1]:
        return False
    request = Request(scope)
    wildcard_accepted = False
    html_rejected = False
    for media_type, quality in _iter_accept_media_types(
        request.headers.get("accept", "")
    ):
        if media_type in {"text/html", "application/xhtml+xml"}:
            if quality == 0:
                html_rejected = True
            else:
                return True
        elif media_type == "*/*" and quality != 0:
            wildcard_accepted = True
    return wildcard_accepted and not html_rejected

The extension test runs first and returns immediately, which is why no Accept header can rescue /users/jane.doe. The two flags handle the case where a client accepts everything but names HTML specifically to refuse it.

The quality values come from a separate parser, which reuses email.message.Message to split a media type from its parameters:

fastapi/routing.py:1908-1914

q = message.get_param("q")
quality = 1.0
if isinstance(q, str):
    try:
        quality = float(q)
    except ValueError:
        pass

This is the line behind the q=wat row in the table above. An unparseable quality value is swallowed and quality keeps its starting 1.0, so a malformed parameter reads as full acceptance while a well-formed q=0 reads as refusal.

8. Failing early

fastapi/routing.py:1810-1814

if check_dir and not os.path.isdir(directory):
    raise RuntimeError(
        f"Frontend directory {directory!r} does not exist. "
        f"Resolved absolute path: {_get_resolved_absolute_path(directory)!r}"
    )

With the default check_dir=True, declared at fastapi/routing.py:2470, a missing directory raises at app creation. With check_dir=False the app builds and the error surfaces on the first request instead, and it propagates to the caller rather than becoming a 500 response (tests/test_frontend.py:707). I ran both: the early error names the resolved absolute path, while the deferred one falls back to Starlette's plainer message. That difference looks like the point of the eager check, though the commit does not say so.

Edge case

The argument guards use assert, so _normalize_frontend_path and the fallback validation raise AssertionError and disappear entirely under python -O. The directory and fallback-file checks use RuntimeError and survive. Two classes of guard, two fates.

Path traversal and symlink escapes are refused, pinned across five encodings at tests/test_frontend.py:663 and for symlinks at tests/test_frontend.py:676. The route also declines to be reversed: url_path_for raises NoMatchFound, and the frontend never appears in the OpenAPI schema.

Quiz

The change puts frontend mounts in a separate _low_priority_routes list consulted after the normal loop, rather than appending them to routes. What does that ordering buy?

Appending to routes would make the outcome depend on where in the file the mount was written, which is exactly the failure mode of app.mount("/", ...). A second list removes the ordering question entirely, and the tests pin it by including the frontend router first and still letting the API route win. Deferring the directory read is what check_dir=False does, and it is unrelated. Speed is not affected: the low-priority list is consulted after the normal loop, so a frontend request does strictly more work, not less.

A build directory holds both index.html and 404.html. The app calls app.frontend("/", directory="dist") with no fallback argument. A browser requests /dashboard with Accept: text/html. What comes back?

Under the default fallback="auto" the 404.html rung is tested before the index.html rung, and it is not gated on the navigation check. Shipping a 404.html in the build therefore turns off deep linking without any configuration change, which is the trap: the app shell is present and never reached. Serving the shell would require removing 404.html or passing fallback="index.html" explicitly. The JSON body appears only when no fallback file applies at all.

Under fallback="index.html", a browser navigates to /users/jane.doe and sends Accept: text/html. The shell is not served. Which fact explains it?

_is_frontend_navigation_request runs os.path.splitext on the final segment and returns False the moment it finds an extension, before it ever constructs the request or looks at headers. A bare text/html is accepted elsewhere, so the header is not the problem, and quality values are optional. Depth is not the problem either: a mount at the root serves /dashboard/settings happily. The consequence is that a username, domain or version in the last segment defeats deep linking.

Delete the clause and _is_frontend_navigation_request(scope) from the index.html rung, leaving everything else intact. Which request changes its answer?

The clause is what keeps missing assets out of the fallback. Without it, any missing path reaches index.html, so a browser asking for a JavaScript file receives HTML with status 200 and reports a syntax error rather than a missing file. A navigation request already passes the clause, so /dashboard is unaffected. The method check sits earlier in get_response and raises 405 before the ladder runs, so a POST never reaches this line at all.

Setting aside frontends entirely, the shape of this change is a general pattern. What is it an instance of?

A frontend mounted at / matches almost everything, which is the definition of a catch-all. The fix is not to make it match less but to ask it last, which is the same reason a default branch goes at the bottom of a dispatch table and a wildcard route goes at the end of a routing file. Caching describes an optimization this change does not make. A guard rejects input early, whereas this arrangement accepts input late, which is closer to the opposite arrangement.