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

Table 1. Serving files and pages at a glance
Tool What it does Typical use Notes

app.mount("/static", StaticFiles(…​))

Serves a directory as an independent ASGI sub-application

CSS, images, downloads, a small hand-written index.html

Not part of the OpenAPI schema. html=True adds directory indexes and a custom 404.html.

Jinja2Templates + TemplateResponse

Renders a Jinja2 template per request into an HTMLResponse

Server-rendered pages and forms, e-mail or report HTML

Needs jinja2. Rendering is synchronous; autoescaping is on for .html.

Hand-written JavaScript with fetch()

Calls the JSON API from the browser

Small interactive pages without a build step

Same-origin deployment needs no CORS; a different origin does.

app.frontend() / router.frontend()

Serves a built front end (dist) as low-priority routes with client-side-routing fallback

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"))
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.

  • directory is the folder on disk. It must exist when the StaticFiles object is created, otherwise Starlette raises RuntimeError at start-up (check_dir=False postpones that; see the table below).

  • name="static" gives the mount a name, which url_for and url_path_for use to build links (see url_for in Templates).

  • Responses carry ETag, Last-Modified, Content-Length and Accept-Ranges; a request with If-None-Match or If-Modified-Since gets 304 Not Modified without a body, and range requests work. Content-Type is guessed from the file extension.

  • Only GET and HEAD are served; other methods return 405. A missing file returns 404.

  • A mounted application is completely independent: its routes are not part of /openapi.json or the interactive docs (the empty list above), and the app’s dependencies, router prefixes and response_model machinery 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 StaticFiles on "/" before your API routes would swallow them all, so mount it last or give it a prefix.

Table 2. StaticFiles options (Starlette 1.7)
Parameter Default Effect

directory

None

Folder to serve files from.

packages

None

A list of Python package names (or (package, directory) tuples) whose statics directory (or the named one) is served as well. This is how libraries ship bundled assets.

html

False

HTML mode: serve index.html for directories and 404.html for missing files (see html=True: Directory Indexes and a Custom 404).

check_dir

True

Fail at creation if directory does not exist. Set False when the folder is created later.

follow_symlink

False

Allow links that leave directory. Leave it off unless you control the links; with it off, a link that points outside the directory is not served.

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)
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.html inside it: /site/ serves static/index.html and /site/docs/ serves static/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, not about): there is no "extensionless URL" lookup.

  • A file named 404.html at the top of the directory is returned, with status 404, for every missing path. Without it the plain 404 response 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

  • StaticFiles streams 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; the ETag and Last-Modified headers above make that safe. See Middleware, CORS & Proxies.

  • The files are served as stored: there is no built-in Cache-Control, minification or compression. Add Cache-Control in 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, .env files, 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]"

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. request is inserted into the context for you (as request), which is what makes url_for work inside templates.

  • status_code, headers, media_type and background (a BackgroundTask) are ordinary response options; the form example below uses status_code.

  • response_class=HTMLResponse on the decorator does not change what is sent (the TemplateResponse already is an HTML response); it tells the OpenAPI schema that the response is text/html, so the interactive docs show it correctly.

  • Templates are looked up with Jinja2’s file-system loader. directory may be a single path or a sequence of paths (searched in order), which lets a project override the templates of an installed package.

  • The TemplateResponse object carries what it rendered. With TestClient, response.template.name and response.context are 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"])
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_processors argument takes functions of the form Callable[[Request], dict]. Every TemplateResponse calls 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. The site_context function above is one.

  • A preconfigured environment. Pass env= instead of directory= to take full control of Jinja2: loaders, filters, globals, strict undefined variables, extensions. Exactly one of directory and env may be given. Starlette still adds its url_for global to a custom environment, unless you already defined your own.

  • Autoescaping. With directory= Starlette creates the environment with select_autoescape(), so templates ending in .html, .htm and .xml escape 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 |safe filter or Markup() 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 or javascript: URLs.

  • Strictness. Jinja2 renders an undefined variable as an empty string by default. A custom environment with undefined=jinja2.StrictUndefined turns 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)
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 function book_detail (the route name defaults to the function name; set name="…​" on the decorator to override it) and fills in the path parameter. The result here is http://testserver/books/1.

  • url_for('static', path='css/site.css') refers to the mount named static; the keyword is path, and a leading slash is accepted too. This is why mounts should always have a name.

  • 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), and app.url_path_for(…​) returns only the path.

Two properties matter in production:

  • url_for returns an absolute URL, built from the host, scheme and root_path the application sees. Behind a TLS terminating proxy the scheme is http unless the proxy headers are trusted, which makes browsers block the assets as mixed content. Configure forwarded headers (--proxy-headers and --forwarded-allow-ips for Uvicorn, or the root_path setting for path-prefix proxies); see Running Behind a Proxy.

  • A route that does not exist, or a missing path parameter, raises NoMatchFound while the template renders, producing a 500 rather 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 %}

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 JSON 422 body, 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. 422 in Starlette 1.x is named HTTP_422_UNPROCESSABLE_CONTENT; the old name HTTP_422_UNPROCESSABLE_ENTITY is deprecated.

  • Post/Redirect/Get. After a successful POST the handler returns 303 See Other, so the browser follows with a GET of the new book. A reload then repeats the harmless GET instead of resubmitting the form and creating a duplicate. 303 is the status meant for this; 307 and 308 would make the browser repeat the POST, and 302 behaves like 303 in practice but is ambiguous.

  • python-multipart is required for Form(); without it FastAPI raises an error at start-up. It is part of fastapi[standard].

  • Not in the API docs. include_in_schema=False keeps 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=Lax or Strict (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 by fetch() is not affected in the same way, because the browser does not attach a bearer token automatically.

  • Methods. HTML forms can only send GET and POST. 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();

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/books without 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 /api to FastAPI so the browser still sees one origin.

  • Check response.ok. fetch() resolves for 404 and 500 too; only network failures reject. Test the status before calling response.json().

  • Use the validation error shape. A 422 body is {"detail": [{"loc": […​], "msg": "…​", "type": "…​"}]}; the script maps loc and msg to a message. See Status Codes & Error Handling for the full shape and for custom handlers.

  • textContent, not innerHTML. Book titles are user data. Building nodes with createElement and textContent (as above) avoids script injection that string-built HTML would allow.

  • Prefix the API. Putting JSON routes under /api keeps 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 Authorization header; for same-site cookie sessions, fetch sends the cookie on same-origin requests (use credentials: "include" for cross-origin ones, which also requires allow_credentials and explicit origins in CORS). See Security Fundamentals.

  • Streaming and WebSockets from the same page. The page can also use EventSource and WebSocket; 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.

Table 3. frontend() parameters
Parameter Values Meaning

path

String starting with /

URL prefix at which the front end is served; "/" for the site root. An empty path or one without a leading slash raises AssertionError.

directory (keyword only)

Path

The build output directory, for example dist.

fallback

"auto" (default), "index.html", "404.html" or None

What to return for a path that is not a file; see Client-Side Routing, Custom 404 and the fallback Options.

check_dir

"auto" (default), True, False

Whether to verify that directory exists when the app is created; see check_dir: Catching a Missing Build.

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:

  • / serves dist/index.html and /assets/app.js serves the script, with the Content-Type guessed 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/settings is not a file. Because the request accepts HTML, the default fallback returns index.html with status 200, and the JavaScript router takes over. Without an Accept: text/html (an API client, a script src, an image) a missing path is a plain 404.

  • Only GET and HEAD are served. A POST to a path with no file is 404, to an existing file it is 405. (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:

Table 4. fallback values
Value Behaviour for a path that is not a file

"auto" (default)

If 404.html exists in the directory, return it with status 404. Otherwise, if index.html exists, return it with status 200 for browser navigation requests. Otherwise a plain 404.

"index.html"

Return index.html with status 200 for GET or HEAD requests that accept text/html or application/xhtml+xml. Other requests get 404. This is the classic single-page-app setting.

"404.html"

Always return 404.html with status 404. This suits generators that emit one HTML file per page (Astro, for example).

None

No fallback: every missing path is a plain 404.

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 Accept header’s media types and their quality values, so a text/html;q=0 is not a navigation. Accept: / (the curl default and the value browsers send for scripts and images) is not treated as a navigation; this is why a mistyped .js URL stays a 404 instead of returning HTML that the browser would try to execute.

  • A browser navigation to a mistyped API URL (/api/nope) will also receive index.html, because the browser asks for HTML. API clients asking for JSON get the usual JSON 404. If that matters, give the API its own sub-path and an explicit catch-all 404 route for it, or use fallback=None and serve the application shell only at known routes.

  • Paths with dots in them (for example /users/john.doe) fall back to index.html like 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. With fallback="index.html" or "404.html" the file is verified when the app is created (unless check_dir is off) and a missing one raises RuntimeError.

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 variable FASTAPI_ENV is development, and performed otherwise. fastapi dev sets FASTAPI_ENV=development for 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 raises RuntimeError when 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 own directory and fallback.

  • 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 an APIRouter(dependencies=[…​]) or in include_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 /admin while / 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 an HttpOnly, SameSite=Lax cookie. In production also add secure=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 303 redirect for the browser. Without the handler, an HTTPException(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 /api to FastAPI, or run fastapi dev with the default check_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 copy dist next 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.html short-lived so new deployments are picked up.

  • The follow_symlink option of the underlying StaticFiles is disabled for front ends; the build directory must contain real files.

StaticFiles(html=True) or app.frontend()?

Table 5. Choosing between the two
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 (index.html)

Yes

Yes

Client-side routing fallback

No (only 404.html)

Yes (index.html for navigation requests)

Custom 404.html

Yes

Yes (fallback="auto" or "404.html")

Dependencies and cookie auth

No; wrap with middleware

Yes, from the app or router

Methods other than GET/HEAD

405

404 (or 405 for an existing file)

Needs name= for url_for

Yes

Not applicable (no names)

Available since

Always

FastAPI 0.138.0

Testing

  • Use TestClient exactly as for the API; response.text contains the HTML and, for templates, response.template and response.context allow asserting on the data. The page Testing covers fixtures and the httpx2 note for Starlette 1.7.

  • Run the tests from the project root (or use absolute paths), so the relative static, templates and dist directories resolve.

  • For app.frontend(), send Accept: text/html to exercise the client-side-routing fallback, and Accept: / (the default) to check that missing assets are 404.

  • In CI, use check_dir=True (or leave "auto" with FASTAPI_ENV unset) so a missing build fails the test run instead of production.

Where Older Guides Are Outdated, and What Changed Recently

  • The TemplateResponse signature. Older tutorials and books write templates.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). Write TemplateResponse(request, "item.html", {"id": id}).

  • Jinja2Templates(directory=…​, **env_options). Starlette 1.0 also removed the extra environment options; build a jinja2.Environment and pass it as env=.

  • Serving a single-page application by hand. Tutorials mount StaticFiles(directory="dist", html=True) and add a catch-all route that returns index.html for every unknown path, or add a custom SPAStaticFiles subclass. Since FastAPI 0.138.0 (2026-06-20) app.frontend() does this properly: low priority behind the API, Accept-aware fallback, 404.html support, 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/HEAD requests return 404, 0.139.0 added dependencies, 0.139.1 fixed dotted paths, 0.141.0 added check_dir="auto", and 0.141.1 fixed headers and background tasks from dependencies.

  • Mounting at / before the routes. Examples that mount StaticFiles on "/" have to do it as the last statement because the mount swallows every later route. app.frontend("/") has no such restriction.

  • async def templates. Guides that suggest aiofiles or async template rendering for speed overlook that TemplateResponse renders synchronously; the practical choices are def path 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_ENTITY was renamed HTTP_422_UNPROCESSABLE_CONTENT (the HTTP specification changed the reason phrase); use the new name in new code.

Table 6. Version history of the features on this page
Version Date Change

Starlette 0.29.0

2023-07-13

TemplateResponse takes the request as its first argument.

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 TemplateResponse(name, context) call and the **env_options parameter of Jinja2Templates are removed.

FastAPI 0.138.0

2026-06-20

app.frontend() and router.frontend().

FastAPI 0.138.2

2026-06-29

Front end returns 404 for methods other than GET and HEAD when no file matches.

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 (/users/john.doe).

FastAPI 0.141.0

2026-07-29

check_dir="auto" (and FASTAPI_ENV support).

FastAPI 0.141.1

2026-07-29

Headers and background tasks from dependencies work in app.frontend().

Pitfalls

  • Relative directory paths 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.

  • jinja2 is not a dependency of plain fastapi; install it (or fastapi[standard]), and python-multipart for forms.

  • Calling TemplateResponse with the template name first fails on Starlette 1.x; the request goes first.

  • Forgetting to name a mount makes url_for('static', …​) raise NoMatchFound; always pass name=.

  • url_for builds absolute URLs from what the app sees; behind a TLS proxy without forwarded headers the links are http and 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; use app.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 200 with an error page after a failed form hides failures from monitoring; return 4xx, and use a 303 redirect after a successful POST.

  • A cookie-authenticated form needs CSRF protection; FastAPI does not provide it.

  • fetch() does not reject on 404 or 500; check response.ok. Use textContent, not innerHTML, for user data.

  • A browser navigation to a mistyped API URL receives the single-page-app index.html from app.frontend(); keep the API under a prefix and handle its unknown paths explicitly if that matters.

  • In production keep check_dir at True or "auto" with FASTAPI_ENV unset, 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.

  • FastAPI is still a 0.x library and app.frontend() is recent: pin the version and read the release notes when upgrading.