Static Files
StaticFiles is an ASGI app that serves a website from the directories behind a Jinja2 loader. It renders .jinja and .j2 templates on each request and converts .md and .markdown files to HTML. Other files, including stylesheets and images, are served unchanged with ETag and Last-Modified support.
The loader determines where pages and assets are found, while the StaticFiles options control URL resolution and HTTP caching. For page content, the Markdown guide explains layouts and embedded Markdown, and the SQL guide explains how templates retrieve data.
Basic usage
Mount StaticFiles in a Starlette app with a FileSystemLoader pointing to your site directory. This example serves files from site at the root URL:
from jinja2 import FileSystemLoader
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette_templates import StaticFiles
app = Starlette(
routes=[
Mount("/", StaticFiles(loader=FileSystemLoader("site")), name="site"),
]
)
StaticFiles reads the filesystem directories exposed by its loader. A FileSystemLoader points to local directories, and a PackageLoader locates files within a Python package. A ChoiceLoader combines them so application files can override files supplied by a theme or framework:
from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader
loader = ChoiceLoader([
FileSystemLoader("site"), # checked first
PackageLoader("myapp", "site"), # fallback
])
StaticFiles serves from the directories behind the loader. A ChoiceLoader checks each loader in order, so an application file can override a framework file, or a theme file can override a base file.
HTML mode
Pass html=True to serve directory index files and a custom 404 page:
StaticFiles(loader=FileSystemLoader("site"), html=True)
With html=True, StaticFiles does three more things:
- A directory URL serves the
indexpage from that directory, under any of the suffixes below. - A directory URL without a trailing slash redirects to add one.
- A request that matches no file falls back to a
404page when one exists, with a404status.
Extensionless URLs resolve with or without html=True. Within each loader directory, a request for /about checks about, about.jinja, about.j2, about.md, about.markdown, about.html, about.html.jinja, and about.html.j2, in that order. For example, about.md takes precedence over about.html in the same directory.
Rendering templates
StaticFiles renders any file ending in .jinja or .j2 on each request. The two suffixes mean the same thing; pick whichever your editor knows. The media type comes from the filename with that suffix removed. So report.csv.jinja becomes text/csv, and page.html.j2 becomes text/html. A .html file with no template suffix is served as it is, not rendered.
Every rendered template has these globals. Markdown rendered through include_markdown() has them too.
| Global | Description |
|---|---|
request |
The Starlette Request for the current request. Read auth state from request.user. |
url_for(path, query_params=None) |
Build a full URL from a path relative to the site root. |
jsonify(data, indent=None) |
Serialize data to a JSON string safe to embed in HTML. It handles datetime, Decimal, and Pydantic models. |
include_markdown(path) |
Render a Markdown file and return it as safe HTML. |
queries, fetch(), fetch_one(), fetch_value() |
Named SQL queries. |
<p>Current path: {{ request.url.path }}</p>
<a href="{{ url_for('/about') }}">About</a>
<a href="{{ url_for('/search', query_params={'q': 'llamas'}) }}">Search</a>
<script type="application/json">{{ jsonify(data) }}</script>
Register your own globals and filters with the global_vars and filters constructor arguments:
StaticFiles(
loader=FileSystemLoader("site"),
global_vars={"site_name": "My Site"},
filters={"shout": str.upper},
)
Your own Jinja extensions
Pass extensions to register your own Jinja extensions in the same environment. Each one is a class or an import path, the same as Jinja's own extensions argument:
StaticFiles(
loader=FileSystemLoader("site"),
extensions=["jinja2.ext.i18n", MyExtension],
)
These extensions join the built-in SQL, Markdown, shortcode, and fragment extensions in the same Jinja environment.
Page context
The page_context option supplies template variables for particular URLs. Each route in the list associates a URL pattern with an async function that returns the variables for matching requests. In this example, the stations page receives a title and the result of load_stations():
from starlette.routing import Route
async def my_stations(request):
return {"title": "Stations", "stations": await load_stations()}
statics = StaticFiles(
loader=FileSystemLoader("templates"),
page_context=[Route("/weather/stations", my_stations)],
)
templates/weather/stations.jinja then writes those variables like any other:
<h1>{{ title }}</h1>
{% for s in stations %}<li>{{ s.name }}</li>{% endfor %}
They reach every surface the page renders through — a shortcode it calls, a {% markdown %} block, and a Markdown file it includes — because all of them render in the page's own context.
The endpoint of an ordinary Route returns a Response. Here it returns the page's variables instead, and nothing calls it as an endpoint. The Route is there for its path: the matching, and the convertors that fill request.path_params before the function runs.
async def station(request):
return {"station": await load_station(request.path_params["code"])}
page_context = [Route("/station/{code:int}", station)]
Five rules govern the list:
- Every route that matches contributes, in declaration order, and a later route wins a name an earlier route also set. So a route for
/{path:path}gives every page a common context, and a route for one page adds to it. - The path is the URL, not the file that answers it. A page reachable under two URLs needs a route for each.
- The path is relative to the mount, like every other path
StaticFilesresolves. A site mounted at/docsstill writesRoute("/weather/stations", ...). requestandqueriesbelong to the page. No context function can overwrite them.- The function is async, and it is awaited inline inside the request's cancellation scope. A synchronous one raises
TypeErrorand names the route at fault.
Fragments get nothing from here. Each has a context function of its own (see HTMX Fragments), because the out-of-band cascade renders a fragment from whatever URL the click went to — a URL no page route matches. A fragment that read the page's variables would paint once and come back empty on every update.
Folders picked up on their own
Two folders under a served directory become Jinja tags without a line of registration:
| Folder | What each file becomes |
|---|---|
shortcodes/ |
Every template file is a Jinja tag of the file's name. See Shortcodes. |
fragments/ |
Every template file is a self-refreshing htmx fragment. See HTMX Fragments. |
A file in either folder may be spelled .html, .jinja, .j2, .html.jinja, or .html.j2. The whole suffix comes off to leave the name, so note.html and note.html.jinja are both the name note — and two files that claim one name fail discovery rather than one of them winning. These folders do not discover .md or .markdown files.
Both need nothing but the folder. A fragment declares its triggers in its own template and renders with request and a QuerySet of its own, so a fragment that displays one query needs no Python. Pass fragments for the fragments that want a context function — a module holding them, a mapping of name to function, a function on its own, or a sequence mixing those:
import store
statics = StaticFiles(loader=FileSystemLoader("templates"), html=True, fragments=store)
StaticFiles discovers fragments during construction and makes them available to HTMXResponse, which can update all stale fragments in one response. It also serves GET /fragment/<name> for fragments configured to fetch their own content. See Update page fragments with htmx to connect those updates to application events.
A ChoiceLoader merges these folders across roots, so a theme and an application can each contribute shortcodes and fragments.
Markdown pages
StaticFiles serves .md and .markdown files as HTML pages. A file such as site/about.md is available at /about and /about.md, with or without html=True. YAML frontmatter supplies optional page variables and a layout.
To wrap several pages in a shared HTML structure, configure a Markdown layout. The same guide explains how frontmatter supplies variables to the page and its layout.
Rendering Markdown inside a template
Use include_markdown() to insert a Markdown file or a {% markdown %} block to write Markdown inline. Both support Jinja and share the template's rendering context.
See Rendering Markdown inside a template for examples and Shortcodes and fragments in Markdown for spacing rules.
Named SQL queries
Templates and Markdown files can declare named SQL queries and run them with fetch(), fetch_one(), or fetch_value(). Supply a query_runner to execute them against your database; declaring a query produces no output.
The SQL guide explains how declarations and fetch calls work together. To connect them to a database client, implement the query runner protocol.
HTTP caching
Plain static files
StaticFiles serves a plain file with ETag and Last-Modified headers and a Cache-Control: public, max-age=N header. max_age defaults to 3600. Set it to None to omit the header and rely on revalidation alone:
StaticFiles(loader=FileSystemLoader("site"), max_age=31536000) # 1 year
StaticFiles(loader=FileSystemLoader("site"), max_age=None) # revalidation only
When a client sends a matching If-None-Match or If-Modified-Since header, StaticFiles returns 304 Not Modified.
Rendered templates
Rendered .jinja templates and Markdown are dynamic: they depend on the request, the user, and the query results. So StaticFiles sets no Cache-Control header by default. Set template_cache_control to control it:
StaticFiles(
loader=FileSystemLoader("site"),
template_cache_control="no-cache",
)
Route order
When you mount StaticFiles alongside other routes, mount it last, or on its own prefix, so it acts as the catch-all:
app = Starlette(
routes=[
Route("/api/health", health),
Mount("/", StaticFiles(loader=FileSystemLoader("site"), html=True), name="site"),
]
)