Static Files, Templates & Frontend
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on
Python 3.13 with TestClient; the fetch() calls of the JavaScript front end were additionally run with Node.js 24
against a real Uvicorn server. FastAPI is first of all an API framework, but real projects also need to hand out CSS and
images, render a few server-side pages (a login form, an admin screen, an e-mail preview) or ship a whole front end
built with Vite, Angular or a similar tool next to the API. FastAPI offers three tools for that: StaticFiles mounts
(from Starlette), Jinja2Templates (from Starlette) and, since 0.138.0, app.frontend() / router.frontend() (native
to FastAPI). The running example is the Bookshelf API with a small HTML front. For the neighbouring topics see
Forms & File Uploads,
Custom Responses,
Middleware, CORS & Proxies,
Dependency Injection and
Sub-applications, WSGI & GraphQL.
Which Tool for Which Job
| Tool | What it does | Typical use | Notes |
|---|---|---|---|
|
Serves a directory as an independent ASGI sub-application |
CSS, images, downloads, a small hand-written |
Not part of the OpenAPI schema. |
|
Renders a Jinja2 template per request into an |
Server-rendered pages and forms, e-mail or report HTML |
Needs |
Hand-written JavaScript with |
Calls the JSON API from the browser |
Small interactive pages without a build step |
Same-origin deployment needs no CORS; a different origin does. |
|
Serves a built front end ( |
React, Vue, Svelte, Angular, Astro and similar static builds |
0.138.0. Static build output only; it does not run server-side rendering. |
The decision in one sentence: a handful of assets or a hand-written page, mount StaticFiles; pages whose HTML depends
on data, use templates; a front-end toolchain that produces a dist directory, use app.frontend().
The Bookshelf Project Layout
The examples share one directory. The static directory holds hand-written assets, templates holds Jinja2 files and
dist stands for the output of a front-end build such as npm run build.
bookshelf/
├── web.py # server-rendered pages (templates and forms)
├── spa_api.py # JSON API plus hand-written JavaScript front end
├── fe1.py # app.frontend() examples
├── static/
│ ├── css/site.css
│ ├── js/books.js
│ ├── docs/index.html
│ └── index.html
├── templates/
│ ├── base.html
│ ├── books.html
│ ├── book.html
│ └── new_book.html
└── dist/ # front-end build output
├── index.html
└── assets/app.js
Relative paths such as directory="static" are resolved against the process working directory, not against the Python
file. Start the app from the project root, or build absolute paths from Path(file).parent.
StaticFiles
StaticFiles is a small ASGI application that maps a URL to a file below a directory. You mount it on a path of the
FastAPI app; everything below that path is handled by it.
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
client = TestClient(app)
r = client.get("/static/css/site.css")
print(r.status_code, r.headers["content-type"])
print(r.headers["etag"], r.headers["last-modified"])
# a conditional request with the ETag is answered without a body
r = client.get("/static/css/site.css", headers={"If-None-Match": r.headers["etag"]})
print(r.status_code)
print(client.get("/static/missing.css").status_code)
print(client.post("/static/css/site.css").status_code)
print(list(client.get("/openapi.json").json()["paths"]))
print(app.url_path_for("static", path="css/site.css"))
Source: FastAPI: Static Files
200 text/css; charset=utf-8
"<etag-of-the-file>" Fri, 02 Oct 2026 09:29:01 GMT
304
404
405
[]
/static/css/site.css
What each argument and result means:
-
The first argument of
mount()("/static") is the URL prefix. Any path that starts with it is handed to the mounted application, with the prefix stripped. -
directoryis the folder on disk. It must exist when theStaticFilesobject is created, otherwise Starlette raisesRuntimeErrorat start-up (check_dir=Falsepostpones that; see the table below). -
name="static"gives the mount a name, whichurl_forandurl_path_foruse to build links (seeurl_forin Templates). -
Responses carry
ETag,Last-Modified,Content-LengthandAccept-Ranges; a request withIf-None-MatchorIf-Modified-Sincegets304 Not Modifiedwithout a body, and range requests work.Content-Typeis guessed from the file extension. -
Only
GETandHEADare served; other methods return405. A missing file returns404. -
A mounted application is completely independent: its routes are not part of
/openapi.jsonor the interactive docs (the empty list above), and the app’s dependencies, router prefixes andresponse_modelmachinery do not apply to it. Middleware added to the main app still wraps it. -
The mount is checked in the order routes were registered. Mounting
StaticFileson"/"before your API routes would swallow them all, so mount it last or give it a prefix.
| Parameter | Default | Effect |
|---|---|---|
|
|
Folder to serve files from. |
|
|
A list of Python package names (or |
|
|
HTML mode: serve |
|
|
Fail at creation if |
|
|
Allow links that leave |
Path traversal is blocked: a request such as /static/%2e%2e/app.py returns 404, because the resolved path must stay
inside the configured directory.
html=True: Directory Indexes and a Custom 404
By default StaticFiles serves a file only when the URL names it exactly. With html=True it behaves like a small
static web server:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
app = FastAPI()
app.mount("/site", StaticFiles(directory="static", html=True), name="site")
client = TestClient(app)
r = client.get("/site/")
print(r.status_code, r.headers["content-type"], "Bookshelf" in r.text)
r = client.get("/site/docs", follow_redirects=False)
print(r.status_code, r.headers["location"])
print(client.get("/site/docs/").text.strip())
print(client.get("/site/about").status_code, client.get("/site/about.html").status_code)
print(client.get("/site/nothing").status_code)
Source: Starlette: Static Files
200 text/html; charset=utf-8 True
307 http://testserver/site/docs/
<h1>Docs home</h1>
404 200
404
The rules, as shown by the output:
-
A directory URL serves the
index.htmlinside it:/site/servesstatic/index.htmland/site/docs/servesstatic/docs/index.html. -
A directory URL without the trailing slash is redirected (
307) to the slash form, so relative links inside the page resolve correctly. -
A file is still served only under its real name (
about.html, notabout): there is no "extensionless URL" lookup. -
A file named
404.htmlat the top of the directory is returned, with status404, for every missing path. Without it the plain404response is used.
Without html=True, /static/docs/ is a 404, even though static/docs/index.html exists. HTML mode is meant for
hand-written sites and documentation builds (for example the HTML generated by a documentation tool). For a
single-page application use app.frontend() instead (app.frontend() and router.frontend()): it builds on the same machinery but adds the
client-side-routing fallback and per-route dependencies.
Operational Notes
-
StaticFilesstreams files through Python and is good for development, internal tools and modest traffic. For high-volume assets put a CDN or the reverse proxy (nginx, Caddy, Traefik) in front and let it cache them; theETagandLast-Modifiedheaders above make that safe. See Middleware, CORS & Proxies. -
The files are served as stored: there is no built-in
Cache-Control, minification or compression. AddCache-Controlin a middleware or at the proxy, and use hashed file names (as front-end builds do) so that a long cache lifetime is safe. -
Never mount a directory that contains anything private (the project root,
.envfiles, source code). Everything below the directory is public. Keep the static directory separate and minimal. -
Uploaded user files belong in a separate, non-executable location (or an object store), served with a controlled
Content-Type; see Forms & File Uploads.
Jinja2 Templates
FastAPI does not ship a template engine; it re-exports Starlette’s Jinja2Templates, a thin wrapper around Jinja2
(from fastapi.templating import Jinja2Templates and from starlette.templating import Jinja2Templates are the same
class). Install Jinja2 first:
pip install jinja2
# or, with the standard extras that include it:
pip install "fastapi[standard]"
Source: FastAPI: Templates
Without jinja2, importing Jinja2Templates raises an ImportError saying so. The fastapi[standard] extra already
installs it.
TemplateResponse(request, name, context)
The pieces: a templates object created once, a path operation that declares a Request, and a call to
templates.TemplateResponse(request, name, context). The Bookshelf pages share a base layout, so the templates use
inheritance:
<!-- templates/base.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Bookshelf{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
</head>
<body>
<nav><a href="{{ url_for('list_books') }}">Books</a> | <a href="{{ url_for('new_book_form') }}">Add</a> | {{ site_name }}</nav>
{% block content %}{% endblock %}
</body>
</html>
<!-- templates/books.html -->
{% extends "base.html" %}
{% block title %}Books{% endblock %}
{% block content %}
<h1>Books</h1>
<ul>
{% for book in books %}
<li><a href="{{ url_for('book_detail', book_id=book.id) }}">{{ book.title }}</a> ({{ book.year }})</li>
{% else %}
<li>No books yet.</li>
{% endfor %}
</ul>
{% endblock %}
<!-- templates/book.html -->
{% extends "base.html" %}
{% block title %}{{ book.title }}{% endblock %}
{% block content %}
<h1>{{ book.title }}</h1>
<p>Published {{ book.year }}</p>
{% endblock %}
And the application:
from dataclasses import dataclass
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@dataclass
class Book:
id: int
title: str
year: int
BOOKS = [Book(1, "Dune", 1965), Book(2, "Solaris", 1961)]
def site_context(request: Request) -> dict[str, str]:
return {"site_name": "Bookshelf"}
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates", context_processors=[site_context])
@app.get("/books", response_class=HTMLResponse)
async def list_books(request: Request):
return templates.TemplateResponse(request, "books.html", {"books": BOOKS})
@app.get("/books/new", response_class=HTMLResponse, include_in_schema=False)
async def new_book_form(request: Request):
return templates.TemplateResponse(request, "new_book.html", {"title": "", "year": ""})
@app.get("/books/{book_id}", response_class=HTMLResponse)
async def book_detail(request: Request, book_id: int):
book = next((b for b in BOOKS if b.id == book_id), None)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
return templates.TemplateResponse(request, "book.html", {"book": book})
(The new_book.html template is shown in Forms Rendered by Templates.) Requesting /books returns a fully rendered page; trimmed to the
interesting lines:
<link rel="stylesheet" href="http://testserver/static/css/site.css">
<nav><a href="http://testserver/books">Books</a> | <a href="http://testserver/books/new">Add</a> | Bookshelf</nav>
<li><a href="http://testserver/books/1">Dune</a> (1965)</li>
<li><a href="http://testserver/books/2">Solaris</a> (1961)</li>
Points to read from this example:
-
The signature is
TemplateResponse(request, name, context=None, status_code=200, headers=None, media_type=None, background=None). The request comes first, then the template name, then the context dictionary.requestis inserted into the context for you (asrequest), which is what makesurl_forwork inside templates. -
status_code,headers,media_typeandbackground(aBackgroundTask) are ordinary response options; the form example below usesstatus_code. -
response_class=HTMLResponseon the decorator does not change what is sent (theTemplateResponsealready is an HTML response); it tells the OpenAPI schema that the response istext/html, so the interactive docs show it correctly. -
Templates are looked up with Jinja2’s file-system loader.
directorymay be a single path or a sequence of paths (searched in order), which lets a project override the templates of an installed package. -
The
TemplateResponseobject carries what it rendered. WithTestClient,response.template.nameandresponse.contextare available, which allows asserting on the data without parsing HTML:
from fastapi.testclient import TestClient
from web import app
client = TestClient(app)
r = client.get("/books/1")
print(r.template.name, sorted(r.context))
print(r.context["book"])
Source: Starlette: Templates
book.html ['book', 'request', 'site_name']
Book(id=1, title='Dune', year=1965)
site_name was never passed by the path operation: it comes from the context processor, explained next.
Context Processors, Custom Environments and Autoescaping
-
Context processors. The
context_processorsargument takes functions of the formCallable[[Request], dict]. EveryTemplateResponsecalls them and merges the results into the context. They are the place for values every page needs: site name, the signed-in user, feature flags, a CSRF token. Thesite_contextfunction above is one. -
A preconfigured environment. Pass
env=instead ofdirectory=to take full control of Jinja2: loaders, filters, globals, strict undefined variables, extensions. Exactly one ofdirectoryandenvmay be given. Starlette still adds itsurl_forglobal to a custom environment, unless you already defined your own. -
Autoescaping. With
directory=Starlette creates the environment withselect_autoescape(), so templates ending in.html,.htmand.xmlescape variables: a book titled<script>alert(1)</script>is rendered as<script>alert(1)</script>and cannot inject script. Never disable autoescaping, never use the|safefilter orMarkup()on user input, and keep in mind that autoescaping protects HTML text and attribute values but not JavaScript or URL contexts: do not put user values inside inline<script>blocks orjavascript:URLs. -
Strictness. Jinja2 renders an undefined variable as an empty string by default. A custom environment with
undefined=jinja2.StrictUndefinedturns a typo or a missing context key into an error, which is what you want in tests:
import jinja2
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.testclient import TestClient
env = jinja2.Environment(
loader=jinja2.FileSystemLoader("templates"),
autoescape=jinja2.select_autoescape(),
undefined=jinja2.StrictUndefined,
)
templates = Jinja2Templates(env=env)
app = FastAPI()
@app.get("/strict")
async def strict(request: Request):
return templates.TemplateResponse(request, "book.html", {}) # no "book" in the context
try:
TestClient(app).get("/strict")
except jinja2.UndefinedError as exc:
print(type(exc).__name__, exc)
Source: Jinja: API — StrictUndefined
UndefinedError 'book' is undefined
url_for in Templates
Jinja2Templates registers a global url_for(name, *path_params) which calls request.url_for(…). It takes the *name
of a route and the path parameters of that route:
-
url_for('book_detail', book_id=book.id)refers to the path operation functionbook_detail(the route name defaults to the function name; setname="…"on the decorator to override it) and fills in the path parameter. The result here ishttp://testserver/books/1. -
url_for('static', path='css/site.css')refers to the mount namedstatic; the keyword ispath, and a leading slash is accepted too. This is why mounts should always have aname. -
The same works in Python:
request.url_for("book_detail", book_id=3)returns an absolute URL (the redirect in the form example uses it), andapp.url_path_for(…)returns only the path.
Two properties matter in production:
-
url_forreturns an absolute URL, built from the host, scheme androot_paththe application sees. Behind a TLS terminating proxy the scheme ishttpunless the proxy headers are trusted, which makes browsers block the assets as mixed content. Configure forwarded headers (--proxy-headersand--forwarded-allow-ipsfor Uvicorn, or theroot_pathsetting for path-prefix proxies); see Running Behind a Proxy. -
A route that does not exist, or a missing path parameter, raises
NoMatchFoundwhile the template renders, producing a500rather than a broken link. That is useful: links to renamed routes fail in tests.
Declare /books/new Before /books/{book_id}
Routes are matched in declaration order. If book_detail (path /books/{book_id}, with book_id: int) were declared
before the /books/new form route, a request for /books/new would match book_detail first and fail validation with
a 422. The example above declares the fixed path first. The general rule is on
Path Parameters.
Rendering Is Synchronous
TemplateResponse(…) renders the template immediately, in the calling thread. In an async def path operation that
means the event loop is busy while a large template renders; in a plain def path operation FastAPI runs the function in
the threadpool, so the same work does not block other requests. For small pages, async def is fine; for heavy
templates (big tables, reports) either use def, or render in a worker thread yourself. The reasoning is the same as in
Async & Concurrency. Jinja2 also has an async mode (enable_async=True
on the environment), but TemplateResponse calls the synchronous renderer, so it does not take advantage of it.
Forms Rendered by Templates
A server-rendered form is two routes: a GET that shows the form and a POST that processes it. The template for the
Bookshelf "add a book" page re-displays the values and the errors, so a failed submission does not lose the user’s input:
<!-- templates/new_book.html -->
{% extends "base.html" %}
{% block content %}
<h1>Add a book</h1>
{% if errors %}
<ul class="error">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
<form method="post" action="{{ url_for('create_book') }}">
<label>Title <input name="title" value="{{ title }}" required></label>
<label>Year <input name="year" type="number" value="{{ year }}" required></label>
<button type="submit">Save</button>
</form>
{% endblock %}
Source: FastAPI: Form Data
The POST handler is appended to the module from the previous section. It reads the fields with Form(), validates
them with a Pydantic model, and uses the Post/Redirect/Get pattern on success:
from typing import Annotated
from fastapi import Form, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field, ValidationError
class BookForm(BaseModel):
title: Annotated[str, Field(min_length=1, max_length=200)]
year: Annotated[int, Field(ge=1450, le=2100)]
@app.post("/books", response_class=HTMLResponse, include_in_schema=False)
async def create_book(
request: Request,
title: Annotated[str, Form()] = "",
year: Annotated[str, Form()] = "",
):
try:
data = BookForm(title=title.strip(), year=year)
except ValidationError as exc:
errors = [f"{err['loc'][0]}: {err['msg']}" for err in exc.errors()]
return templates.TemplateResponse(
request,
"new_book.html",
{"title": title, "year": year, "errors": errors},
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
book = Book(id=len(BOOKS) + 1, title=data.title, year=data.year)
BOOKS.append(book)
return RedirectResponse(
request.url_for("book_detail", book_id=book.id),
status_code=status.HTTP_303_SEE_OTHER,
)
The calls, with the real responses of a local Uvicorn run:
curl -si -X POST -d "title=Hyperion&year=1989" http://127.0.0.1:8000/books
curl -s -o /dev/null -w "%{http_code}\n" -X POST -d "title=&year=abc" http://127.0.0.1:8000/books
HTTP/1.1 303 See Other
content-length: 0
location: http://127.0.0.1:8000/books/3
422
Why the handler is written this way:
-
The fields are read as strings, validated afterwards. If the handler declared
year: Annotated[int, Form(ge=1450)], invalid input would be rejected by FastAPI itself with a JSON422body, which is right for an API but useless for a person looking at a form. Reading strings and validating with a Pydantic model (BookForm) lets the handler render the same page again with the user’s values and readable messages. (A form model,Annotated[BookForm, Form()], is the equivalent for API-style handling; see Forms & File Uploads.) -
Re-rendering with
422. The failed submission answers with the form again and a client-error status. Browsers show the page either way; the status code is for tests, proxies and monitoring.422in Starlette 1.x is namedHTTP_422_UNPROCESSABLE_CONTENT; the old nameHTTP_422_UNPROCESSABLE_ENTITYis deprecated. -
Post/Redirect/Get. After a successful
POSTthe handler returns303 See Other, so the browser follows with aGETof the new book. A reload then repeats the harmlessGETinstead of resubmitting the form and creating a duplicate.303is the status meant for this;307and308would make the browser repeat thePOST, and302behaves like303in practice but is ambiguous. -
python-multipartis required forForm(); without it FastAPI raises an error at start-up. It is part offastapi[standard]. -
Not in the API docs.
include_in_schema=Falsekeeps the browser-only form routes out of/openapi.json, which stays a description of the JSON API. -
CSRF. A cookie-authenticated form is a target for cross-site request forgery. FastAPI has no built-in protection: set session cookies with
SameSite=LaxorStrict(see Headers & Cookies), and for state-changing forms add a per-session token (a hidden field compared on the server) through a context processor and a dependency. A token-authenticated JSON API called byfetch()is not affected in the same way, because the browser does not attach a bearer token automatically. -
Methods. HTML forms can only send
GETandPOST. For "delete" or "update" buttons, post to a dedicated URL (/books/3/delete) or call the JSON API from JavaScript (next section).
A JavaScript fetch() Front End
The server-rendered approach reloads the page on every action. A small page that talks to the JSON API with fetch()
needs no template engine and no build step: three hand-written files served by StaticFiles, and an API. This keeps the
FastAPI part trivial and the JavaScript part honest about errors.
The API first (a model with validation, a list and a create route, and the index.html served at /):
from typing import Annotated
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
class BookIn(BaseModel):
title: Annotated[str, Field(min_length=1, max_length=200)]
year: Annotated[int, Field(ge=1450, le=2100)]
class Book(BookIn):
id: int
BOOKS: list[Book] = [Book(id=1, title="Dune", year=1965)]
app = FastAPI()
@app.get("/api/books")
async def list_books() -> list[Book]:
return BOOKS
@app.post("/api/books", status_code=201)
async def create_book(book: BookIn) -> Book:
created = Book(id=len(BOOKS) + 1, **book.model_dump())
BOOKS.append(created)
return created
@app.get("/", include_in_schema=False)
async def home() -> FileResponse:
return FileResponse("static/index.html")
app.mount("/static", StaticFiles(directory="static"), name="static")
The page and the script:
<!-- static/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bookshelf</title>
<link rel="stylesheet" href="/static/css/site.css">
</head>
<body>
<h1>Bookshelf</h1>
<ul id="books"></ul>
<form id="add-book">
<input name="title" required>
<input name="year" type="number" required>
<button>Add</button>
</form>
<p id="message" class="error"></p>
<script src="/static/js/books.js" type="module"></script>
</body>
</html>
// static/js/books.js
async function loadBooks() {
const response = await fetch("/api/books");
if (!response.ok) {
throw new Error(`GET /api/books failed: ${response.status}`);
}
const books = await response.json();
const list = document.querySelector("#books");
list.replaceChildren(
...books.map((book) => {
const item = document.createElement("li");
item.textContent = `${book.title} (${book.year})`;
return item;
}),
);
}
document.querySelector("#add-book").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
const response = await fetch("/api/books", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: form.get("title"), year: Number(form.get("year")) }),
});
const message = document.querySelector("#message");
if (response.status === 422) {
const problem = await response.json();
message.textContent = problem.detail.map((d) => `${d.loc.at(-1)}: ${d.msg}`).join("; ");
return;
}
message.textContent = "";
event.target.reset();
await loadBooks();
});
loadBooks();
Source: MDN: Using the Fetch API
The server side was run with TestClient and the same two requests the script makes were sent with Node.js fetch() to a
real Uvicorn process; the script itself was syntax-checked with node --check:
GET /api/books -> 200 [{"title":"Dune","year":1965,"id":1}]
POST /api/books {"title":"", "year":3}
-> 422 title: String should have at least 1 character; year: Input should be greater than or equal to 1450
What the script demonstrates, and what to copy from it:
-
Same origin, no CORS. The page and the API are served by the same application, so the browser calls
/api/bookswithout any cross-origin machinery. If the front end is served from another origin (a Vite dev server on port 5173, a CDN), the API needs CORS configured as described in CORS; alternatively, make the dev server proxy/apito FastAPI so the browser still sees one origin. -
Check
response.ok.fetch()resolves for404and500too; only network failures reject. Test the status before callingresponse.json(). -
Use the validation error shape. A
422body is{"detail": [{"loc": […], "msg": "…", "type": "…"}]}; the script mapslocandmsgto a message. See Status Codes & Error Handling for the full shape and for custom handlers. -
textContent, notinnerHTML. Book titles are user data. Building nodes withcreateElementandtextContent(as above) avoids script injection that string-built HTML would allow. -
Prefix the API. Putting JSON routes under
/apikeeps URL space for pages and assets and makes it easy to proxy or route them separately. -
Authentication. For a bearer-token API, keep the token in memory or in a short-lived store and send it in the
Authorizationheader; for same-site cookie sessions,fetchsends the cookie on same-origin requests (usecredentials: "include"for cross-origin ones, which also requiresallow_credentialsand explicit origins in CORS). See Security Fundamentals. -
Streaming and WebSockets from the same page. The page can also use
EventSourceandWebSocket; see Streaming, JSON Lines & SSE and WebSockets.
app.frontend() and router.frontend()
Front-end frameworks (React, Vue, Svelte, Angular, Solid, Astro and others) compile a project into a directory of static
files, commonly dist. FastAPI 0.138.0 (2026-06-20) added app.frontend() and router.frontend() to serve exactly such
a directory next to the API, following the conventions those build outputs need. Under the hood it is StaticFiles
in HTML mode with extra rules, but registered as low-priority routes: FastAPI path operations are tried first, and the
front-end files are consulted only when no normal route matched, so the API is never shadowed regardless of the order in
which you write the code.
The Signature
FastAPI.frontend(
path: str,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
check_dir: bool | Literal["auto"] = "auto",
) -> None
APIRouter.frontend() has the same parameters. Source of the signature: the installed FastAPI 0.142.2
(fastapi.applications.FastAPI.frontend and fastapi.routing.APIRouter.frontend), which matches the tutorial.
Source: FastAPI: Frontend
| Parameter | Values | Meaning |
|---|---|---|
|
String starting with |
URL prefix at which the front end is served; |
|
Path |
The build output directory, for example |
|
|
What to return for a path that is not a file; see Client-Side Routing, Custom 404 and the |
|
|
Whether to verify that |
There is no dependencies parameter: dependencies come from the app or the router the front end is registered on
(Dependencies and Middleware: Protecting a Front End with a Cookie).
Serving a Build
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/books")
async def list_books() -> list[str]:
return ["Dune"]
app.frontend("/", directory="dist")
Observed behaviour with TestClient (a browser navigation sends Accept: text/html; TestClient and curl send
/, so the examples set the header explicitly when it matters):
from fastapi.testclient import TestClient
client = TestClient(app)
html = {"Accept": "text/html"}
print(client.get("/").status_code, client.get("/assets/app.js").headers["content-type"])
print(client.get("/api/books").json())
print(client.get("/dashboard/settings", headers=html).status_code) # client-side route
print(client.get("/dashboard/settings", headers={"Accept": "*/*"}).status_code)
print(client.get("/assets/missing.js", headers={"Accept": "*/*"}).status_code)
print(client.post("/dashboard").status_code, client.post("/assets/app.js").status_code)
print(list(client.get("/openapi.json").json()["paths"]))
200 text/javascript; charset=utf-8
['Dune']
200
404
404
404 405
['/api/books']
How to read it:
-
/servesdist/index.htmland/assets/app.jsserves the script, with theContent-Typeguessed from the extension. -
The API route is untouched, and
app.frontend()adds nothing to the OpenAPI schema (the last line). -
A client-side route such as
/dashboard/settingsis not a file. Because the request accepts HTML, the default fallback returnsindex.htmlwith status200, and the JavaScript router takes over. Without anAccept: text/html(an API client, a scriptsrc, an image) a missing path is a plain404. -
Only
GETandHEADare served. APOSTto a path with no file is404, to an existing file it is405. (Behaviour introduced in 0.138.2, 2026-06-29.) -
A path operation with the same path wins over a file with that name; the front end is the lowest priority.
Client-Side Routing, Custom 404 and the fallback Options
Single-page applications use the browser’s History API: /books/42/reviews is a screen, not a file. When a user reloads
or bookmarks such a URL, the server must answer with index.html so the application can render it. That is what
fallback controls:
| Value | Behaviour for a path that is not a file |
|---|---|
|
If |
|
Return |
|
Always return |
|
No fallback: every missing path is a plain |
Each value was run against a dist directory (and a dist404 copy that also has a 404.html):
from fastapi import FastAPI
from fastapi.testclient import TestClient
html = {"Accept": "text/html"}
app = FastAPI()
app.frontend("/", directory="dist404") # auto, with a 404.html present
r = TestClient(app).get("/nope", headers=html)
print("auto + 404.html:", r.status_code, r.text.strip())
app = FastAPI()
app.frontend("/", directory="dist404", fallback="index.html")
print("index.html:", TestClient(app).get("/nope", headers=html).status_code)
app = FastAPI()
app.frontend("/", directory="dist", fallback=None)
print("None:", TestClient(app).get("/nope", headers=html).status_code, TestClient(app).get("/").status_code)
auto + 404.html: 404 <h1>Not found</h1>
index.html: 200
None: 404 200
Notes:
-
The check "does this request want HTML" uses the
Acceptheader’s media types and their quality values, so atext/html;q=0is not a navigation.Accept: /(thecurldefault and the value browsers send for scripts and images) is not treated as a navigation; this is why a mistyped.jsURL stays a404instead of returning HTML that the browser would try to execute. -
A browser navigation to a mistyped API URL (
/api/nope) will also receiveindex.html, because the browser asks for HTML. API clients asking for JSON get the usual JSON404. If that matters, give the API its own sub-path and an explicit catch-all404route for it, or usefallback=Noneand serve the application shell only at known routes. -
Paths with dots in them (for example
/users/john.doe) fall back toindex.htmllike any other client-side route (fixed in 0.139.1, 2026-07-16); older releases treated them as missing files. -
The fallback files are looked up at the top of
directory. Withfallback="index.html"or"404.html"the file is verified when the app is created (unlesscheck_diris off) and a missing one raisesRuntimeError.
check_dir: Catching a Missing Build
check_dir decides whether directory is checked when the app object is created:
-
"auto"(default, since 0.141.0, 2026-07-29): the check is skipped, with a warning, when the environment variableFASTAPI_ENVisdevelopment, and performed otherwise.fastapi devsetsFASTAPI_ENV=developmentfor you if it is not already set. This lets you start the backend before the front end has ever been built. -
True: always check; a missing directory raisesRuntimeErrorwhen the app is created. Good for CI and production images, where a missing front end is a deployment bug that should stop the start-up. -
False: never check at start-up. If the directory is still missing when a request arrives, FastAPI raises then. Use it when the build output is created after the app object, for instance by a build step that runs later in the same container start.
import os
import warnings
from fastapi import FastAPI
try:
FastAPI().frontend("/", directory="missing-dist") # auto, outside development
except RuntimeError as exc:
print("auto:", exc)
FastAPI().frontend("/", directory="missing-dist", check_dir=False) # no error yet
os.environ["FASTAPI_ENV"] = "development" # what `fastapi dev` sets
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
FastAPI().frontend("/", directory="missing-dist")
print("development:", [str(w.message) for w in caught])
auto: Frontend directory 'missing-dist' does not exist. Resolved absolute path: '/.../missing-dist'
development: ["Frontend directory 'missing-dist' does not exist. Resolved absolute path: '/.../missing-dist'"]
The message names both the configured path and the absolute path it resolved to, which is the first thing to compare when a container runs from a different working directory than the developer’s machine.
router.frontend(), Prefixes and Several Front Ends
APIRouter.frontend() registers the front end on a router, so that include_router(prefix=…) mounts it below a
prefix:
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
router = APIRouter()
router.frontend("/", directory="dist", fallback="index.html")
app.include_router(router, prefix="/app")
client = TestClient(app)
html = {"Accept": "text/html"}
print(client.get("/app/", headers=html).status_code, client.get("/app/assets/app.js").status_code)
print(client.get("/app/x/y", headers=html).status_code, client.get("/x", headers=html).status_code)
# several front ends: the most specific path wins
app = FastAPI()
app.frontend("/", directory="dist404")
app.frontend("/admin", directory="dist")
client = TestClient(app)
print(client.get("/admin/zzz", headers=html).status_code, client.get("/zzz", headers=html).status_code)
200 200
200 404
200 404
-
The front end is served under the router’s prefix (
/app/here); a path outside it (/x) is not served. -
Regular path operations still take precedence, including those of other routers.
-
Several front ends can coexist (a public site at
/and an admin app at/admin). The request is matched to the most specific path; each keeps its owndirectoryandfallback. -
Because it is a router feature, a front end can be packaged inside a feature router (the layout from Project Structure & Layered Architecture) and included like any other part of the application.
Dependencies and Middleware: Protecting a Front End with a Cookie
Front-end responses run inside the normal FastAPI application:
-
HTTP middleware (including CORS, GZip and your own) applies to them.
-
Dependencies declared on the app (
FastAPI(dependencies=[…])), on anAPIRouter(dependencies=[…])or ininclude_router(…, dependencies=[…])also run for front-end requests (0.139.0, 2026-07-01). A dependency can raise an exception to refuse the request, add response headers, and register background tasks (headers and background tasks from dependencies work from 0.141.1, 2026-07-29). The dependencies of a router apply only to the front ends registered on that router, which makes it possible to protect/adminwhile/stays public.
The next example protects an admin front end with a session cookie, sends unauthenticated browsers to a login page, and leaves the public site and the login page open:
import secrets
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, FastAPI, Request, Response
from fastapi.responses import RedirectResponse
SESSIONS: dict[str, str] = {} # session id -> username (use a real store in production)
class NotSignedIn(Exception):
pass
def current_user(session: Annotated[str | None, Cookie()] = None) -> str:
if session is None or session not in SESSIONS:
raise NotSignedIn
return SESSIONS[session]
app = FastAPI()
@app.exception_handler(NotSignedIn)
async def redirect_to_login(request: Request, exc: NotSignedIn) -> RedirectResponse:
return RedirectResponse("/login", status_code=303)
@app.post("/api/login")
async def login(response: Response) -> dict[str, str]:
session_id = secrets.token_urlsafe(32)
SESSIONS[session_id] = "alice"
response.set_cookie("session", session_id, httponly=True, samesite="lax")
return {"user": "alice"}
admin = APIRouter(dependencies=[Depends(current_user)])
admin.frontend("/", directory="dist")
app.include_router(admin, prefix="/admin")
app.frontend("/login", directory="dist")
from fastapi.testclient import TestClient
client = TestClient(app)
html = {"Accept": "text/html"}
r = client.get("/admin/", headers=html, follow_redirects=False)
print(r.status_code, r.headers["location"])
print(client.get("/login/", headers=html).status_code)
client.post("/api/login") # the client keeps the cookie
print(client.get("/admin/", headers=html).status_code)
print(client.get("/admin/assets/app.js").status_code)
print(client.get("/admin/reports/1", headers=html).status_code)
print(TestClient(app).get("/admin/assets/app.js", follow_redirects=False).status_code)
303 /login
200
200
200
200
303
Points about this design:
-
The session id is random (
secrets.token_urlsafe), stored on the server, and sent in anHttpOnly,SameSite=Laxcookie. In production also addsecure=True, an expiry, and a persistent store (a database or Redis) instead of a dictionary. See Security Fundamentals and OAuth2 Password Flow & JWT for the token-based alternatives. -
Assets are protected too. The dependency runs for every request below
/admin, including/admin/assets/app.js, so an anonymous client cannot read the application bundle (the last line). That is usually what you want for an internal tool. A public site that only has private data should instead leave the front end open and protect the API. -
The exception handler turns "not signed in" into a
303redirect for the browser. Without the handler, anHTTPException(401)raised by the dependency is returned as JSON, which is correct for an API but unfriendly for a browser navigation. -
The front end is only the delivery of files. The real authorization boundary is the API behind it: every JSON route must check the user itself, because anybody can call the API without loading the front end.
Development Workflow and Limits
-
During development run the front-end dev server (Vite and others) with a proxy for
/apito FastAPI, or runfastapi devwith the defaultcheck_dir="auto"so the backend starts without a build. For production, build the front end in the image (a multi-stage Docker build is the usual way) and copydistnext to the application; see Deployment. -
app.frontend()serves static build output only. It performs no server-side rendering. Frameworks that need a Node process to render each request (Next.js in SSR mode, Nuxt SSR, Remix) are served separately, with FastAPI as the API behind them. -
Files are streamed from disk by FastAPI; behind a proxy or CDN, let the edge cache the hashed assets and keep
index.htmlshort-lived so new deployments are picked up. -
The
follow_symlinkoption of the underlyingStaticFilesis disabled for front ends; the build directory must contain real files.
StaticFiles(html=True) or app.frontend()?
| Aspect | app.mount(…, StaticFiles(html=True)) |
app.frontend() |
|---|---|---|
Registered as |
A mounted ASGI application, matched in declaration order |
Low-priority routes, checked after all path operations |
In OpenAPI |
No |
No |
Directory index ( |
Yes |
Yes |
Client-side routing fallback |
No (only |
Yes ( |
Custom |
Yes |
Yes ( |
Dependencies and cookie auth |
No; wrap with middleware |
Yes, from the app or router |
Methods other than |
|
|
Needs |
Yes |
Not applicable (no names) |
Available since |
Always |
FastAPI 0.138.0 |
Testing
-
Use
TestClientexactly as for the API;response.textcontains the HTML and, for templates,response.templateandresponse.contextallow asserting on the data. The page Testing covers fixtures and thehttpx2note for Starlette 1.7. -
Run the tests from the project root (or use absolute paths), so the relative
static,templatesanddistdirectories resolve. -
For
app.frontend(), sendAccept: text/htmlto exercise the client-side-routing fallback, andAccept: /(the default) to check that missing assets are404. -
In CI, use
check_dir=True(or leave"auto"withFASTAPI_ENVunset) so a missing build fails the test run instead of production.
Where Older Guides Are Outdated, and What Changed Recently
-
The
TemplateResponsesignature. Older tutorials and books writetemplates.TemplateResponse("item.html", {"request": request, "id": id}): name first, and the request as a key of the context. Starlette 0.29.0 (2023-07-13) added the request as the first argument, FastAPI adopted it in 0.108.0 (2023-12-26), and Starlette 1.0 (release candidate 2026-02-23, final 2026-03-22) removed the old form. With Starlette 1.7 the old call fails (TypeError: unhashable type: 'dict', because the context dictionary lands in the name position). WriteTemplateResponse(request, "item.html", {"id": id}). -
Jinja2Templates(directory=…, **env_options). Starlette 1.0 also removed the extra environment options; build ajinja2.Environmentand pass it asenv=. -
Serving a single-page application by hand. Tutorials mount
StaticFiles(directory="dist", html=True)and add a catch-all route that returnsindex.htmlfor every unknown path, or add a customSPAStaticFilessubclass. Since FastAPI 0.138.0 (2026-06-20)app.frontend()does this properly: low priority behind the API,Accept-aware fallback,404.htmlsupport, and (0.139.0, 2026-07-01) dependencies. The release history of the feature: 0.138.0 added it, 0.138.2 made non-GET/HEADrequests return404, 0.139.0 added dependencies, 0.139.1 fixed dotted paths, 0.141.0 addedcheck_dir="auto", and 0.141.1 fixed headers and background tasks from dependencies. -
Mounting at
/before the routes. Examples that mountStaticFileson"/"have to do it as the last statement because the mount swallows every later route.app.frontend("/")has no such restriction. -
async deftemplates. Guides that suggestaiofilesor async template rendering for speed overlook thatTemplateResponserenders synchronously; the practical choices aredefpath operations for heavy pages or caching. -
Static asset serving in production. Older advice assumed that serving assets from Python was acceptable at scale. It is fine for modest traffic, but proxies and CDNs are the better place for high-volume assets; the application still needs the files for development and tests.
-
Front end and CORS. Guides often enable permissive CORS "to make the front end work". When the front end is served by the same application (any of the approaches on this page) no CORS is needed; configure it only for a separate origin.
-
The status code constants.
HTTP_422_UNPROCESSABLE_ENTITYwas renamedHTTP_422_UNPROCESSABLE_CONTENT(the HTTP specification changed the reason phrase); use the new name in new code.
| Version | Date | Change |
|---|---|---|
Starlette 0.29.0 |
2023-07-13 |
|
FastAPI 0.108.0 |
2023-12-26 |
Upgrades Starlette and the docs to the request-first template call. |
Starlette 1.0 |
2026-03-22 (RC 2026-02-23) |
The old |
FastAPI 0.138.0 |
2026-06-20 |
|
FastAPI 0.138.2 |
2026-06-29 |
Front end returns |
FastAPI 0.139.0 |
2026-07-01 |
Dependencies apply to front ends (cookie authentication). |
FastAPI 0.139.1 |
2026-07-16 |
Fallback works for paths containing dots ( |
FastAPI 0.141.0 |
2026-07-29 |
|
FastAPI 0.141.1 |
2026-07-29 |
Headers and background tasks from dependencies work in |
Pitfalls
-
Relative
directorypaths depend on the working directory of the process; run from the project root or build absolute paths. A missing directory fails at start-up by default. -
jinja2is not a dependency of plainfastapi; install it (orfastapi[standard]), andpython-multipartfor forms. -
Calling
TemplateResponsewith the template name first fails on Starlette 1.x; the request goes first. -
Forgetting to name a mount makes
url_for('static', …)raiseNoMatchFound; always passname=. -
url_forbuilds absolute URLs from what the app sees; behind a TLS proxy without forwarded headers the links arehttpand the browser blocks them as mixed content. -
Declare fixed routes (
/books/new) before parametrised ones (/books/{book_id}). -
StaticFiles(html=True)does not provide client-side routing; useapp.frontend()for single-page applications. -
Mounted applications are invisible in OpenAPI and do not get the app’s dependencies. Do not rely on them for authorization.
-
Never mount a directory that contains secrets, source or uploaded executable content.
-
Never disable autoescaping and never mark user input as safe; do not interpolate user values into inline scripts.
-
Returning a
200with an error page after a failed form hides failures from monitoring; return4xx, and use a303redirect after a successfulPOST. -
A cookie-authenticated form needs CSRF protection; FastAPI does not provide it.
-
fetch()does not reject on404or500; checkresponse.ok. UsetextContent, notinnerHTML, for user data. -
A browser navigation to a mistyped API URL receives the single-page-app
index.htmlfromapp.frontend(); keep the API under a prefix and handle its unknown paths explicitly if that matters. -
In production keep
check_diratTrueor"auto"withFASTAPI_ENVunset, so a deployment without the front end build fails at start-up. -
app.frontend()is not server-side rendering; it only serves the files a build produced. -
FastAPIis still a0.xlibrary andapp.frontend()is recent: pin the version and read the release notes when upgrading.