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:
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.
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
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.
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.
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?
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?
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?
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?
/ 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.