Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Starlette-Templates

Documentation | PyPI

Starlette-Templates serves a folder of Jinja templates, Markdown, and static files as a website. A .jinja or .j2 file renders on each request, and every other file goes out unchanged with ETag and Last-Modified support.

Installation

Starlette-Templates needs Python 3.10 or later.

pip install starlette-templates

Quick start

from jinja2 import PackageLoader
from starlette.applications import Starlette
from starlette.routing import Mount

from starlette_templates import StaticFiles

app = Starlette(
    routes=[
        # PackageLoader reads myapp/site/, next to myapp/__init__.py
        Mount("/", StaticFiles(loader=PackageLoader("myapp", "site"), html=True), name="site"),
    ]
)

myapp/site/index.jinja renders on each request:

<!DOCTYPE html>
<link rel="stylesheet" href="{{ url_for('/style.css') }}">
<h1>Hello</h1>
<p>You asked for {{ request.url.path }}.</p>

Run uvicorn app:app --reload and open http://localhost:8000/. html=True resolves the directory URL to myapp/site/index.jinja. /style.css goes out unchanged, with an ETag and Cache-Control: public, max-age=3600.

Static files

StaticFiles is a raw ASGI app. It serves the directories that a Jinja2 loader reads.

Only loader is required. The call below names every argument with its default; money, station, and SqliteRunner are your own code:

from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader
from starlette.routing import Route

from starlette_templates import StaticFiles

import weather  # a module of fragment context functions

static = StaticFiles(
    # A ChoiceLoader checks each loader in turn, so a file in your app can
    # override a file that a framework or a theme ships.
    loader=ChoiceLoader([
        FileSystemLoader("site"),  # checked first
        PackageLoader("mytheme", "site"),  # fallback
    ]),
    html=True,  # serve the index page for a directory URL, and a 404 page on a miss
    check_dir=True,  # raise on the first request when a served directory is missing
    follow_symlink=False,  # keep a symlinked path inside the served directory
    max_age=3600,  # Cache-Control for plain files; None omits the header
    template_cache_control=None,  # Cache-Control for rendered templates; the default is none
    global_vars={"site_name": "Weather"},  # more template globals
    filters={"money": money},  # more Jinja filters
    extensions=["jinja2.ext.i18n"],  # your own Jinja extensions
    fragments=weather,  # context functions for the htmx fragments
    page_context=[Route("/weather/{code}", station)],  # variables for one page
    query_runner=SqliteRunner("weather.db"),  # runs the named SQL queries
)

With html=True, a request for /docs/ serves docs/index.jinja, and a request for /docs redirects to /docs/. A request that matches no file falls back to a 404 page when one exists.

URLs need no extension. A request for /about finds about.jinja, about.j2, about.html, about.html.jinja, or about.html.j2.

Template globals

Every rendered template gets these variables, and so does the Markdown that include_markdown() renders:

{# request — the Starlette Request. Read auth state from request.user. #}
<p>Current path: {{ request.url.path }}</p>

{# url_for — a full URL from a path relative to the site root #}
<a href="{{ url_for('/search', query_params={'q': 'llamas'}) }}">Search</a>

{# jsonify — JSON safe to embed in HTML. It handles datetime, Decimal, and Pydantic models. #}
<script type="application/json">{{ jsonify(dict(request.query_params)) }}</script>

{# include_markdown — a Markdown file rendered to safe HTML. The file may contain Jinja. #}
<article>{{ include_markdown('intro.md') }}</article>

{# queries, fetch(), fetch_one(), fetch_value() — named SQL queries, below #}

Add your own with the global_vars and filters arguments.

Markdown in a template

A {% markdown %} block writes Markdown inside the HTML, with no separate file:

<article class="prose">
  {% markdown %}
  # Welcome

  Text with **bold** and a [link]({{ url_for('/about') }}).

  - one
  - two
  {% endmarkdown %}
</article>

Jinja runs on the body first and the Markdown conversion runs second, so variables, filters, shortcodes, and fragment tags all work inside the block. The block strips its own indentation before the conversion, so a block that you indent to match the HTML around it stays out of a code block. Autoescape runs on the body, so a value cannot inject markup of its own.

Page context

Each route in page_context pairs a URL with an async function, and the page at that URL renders with the variables the function returns:

from jinja2 import FileSystemLoader
from starlette.routing import Route

from starlette_templates import StaticFiles


async def site_wide(request):
    return {"site_name": "Weather", "year": 2026}


async def one_station(request):
    code = request.path_params["code"]  # the route's convertors fill path_params
    return {"station": await load_station(code)}


static = StaticFiles(
    loader=FileSystemLoader("site"),
    page_context=[
        Route("/{path:path}", site_wide),  # every page
        Route("/weather/{code}", one_station),  # and this one as well
    ],
)

site/weather/{code}.jinja then writes those variables like any others:

<h1>{{ station.name }}{{ site_name }}</h1>

Every route that matches adds its variables, in the order that you declare them, so a later route wins when two routes return the same name. StaticFiles uses the Route for its path and its convertors alone, and nothing calls the function as an endpoint. Every shortcode the page calls reads these variables too, and so does every {% markdown %} block and every Markdown file the page includes. A synchronous context function raises TypeError naming its route.

Named SQL queries

A template declares a named query with the {% sql %} tag. Declaring it produces no output. The query runs when the template calls fetch() on it:

{% sql stations from weather %}
SELECT name, elevation FROM stations WHERE country = :country
{% endsql %}

{% sql totals from weather %}
SELECT count(*) FROM stations WHERE country = :country
{% endsql %}

<p>{{ fetch_value(queries.totals, 0) }} stations.</p>

<ul>
{% for s in fetch(queries.stations) %}
  <li>{{ s.name }} at {{ s.elevation }}m</li>
{% endfor %}
</ul>

The query string of the request fills the :name placeholders, so ?country=US fills :country. The runner hands the values to the database as parameters and never writes them into the SQL text, so a visitor cannot inject SQL through the URL. A path parameter binds nothing, so a placeholder the URL leaves unfilled reaches the runner unbound and the runner raises.

The three helpers return different shapes:

{{ fetch(queries.stations) }}          {# every row, as a list of dicts #}
{{ fetch_one(queries.stations) }}      {# the first row, or the default #}
{{ fetch_value(queries.totals, 0) }}   {# the first column of the first row, such as a COUNT #}

Markdown declares a query as a fenced code block instead, and the {% sql %} tag works there too:

```sql totals from weather
SELECT count(*) FROM stations
```

There are {{ fetch_value(queries.totals) }} stations.

Supply a runner that implements the QueryRunner protocol. This one uses aiosqlite and runs every query against a single SQLite file:

import aiosqlite
from jinja2 import FileSystemLoader

from starlette_templates import StaticFiles
from starlette_templates.staticfiles import Query, Row


class SqliteRunner:
    def __init__(self, path: str) -> None:
        self.path = path

    async def run(self, query: Query, params: dict) -> list[Row]:
        async with aiosqlite.connect(self.path) as conn:
            conn.row_factory = aiosqlite.Row
            async with conn.execute(query.sql, params) as cursor:
                return [dict(row) for row in await cursor.fetchall()]


static = StaticFiles(loader=FileSystemLoader("site"), query_runner=SqliteRunner("weather.db"))

A runner that serves several databases reads query.database to pick a connection. The runner above ignores it and uses one file. Without a runner, fetch() returns an empty list, so a site with no database still serves.

Shortcodes

Put a shortcodes/ folder in a served directory. Every template file in it becomes a Jinja tag, named after the file without its suffix, slugified with underscores:

shortcodes/
    youtube.html                  {% youtube %}
    onboarding-wizard-modal.html  {% onboarding_wizard_modal %}
    note.html                     {% note %} ... {% endnote %}

When the template uses the inner variable, the shortcode is paired and the caller closes it with {% end<name> %}. Otherwise it is void and takes no end tag:

{# shortcodes/youtube.html — no `inner`, so the tag is void #}
<iframe src="https://www.youtube.com/embed/{{ id }}" title="{{ title }}"></iframe>

{# shortcodes/note.html — uses `inner`, so the tag is paired #}
<div class="note note-{{ kind }}">{{ inner }}</div>

A page calls them like this, and so does a Markdown file included with include_markdown():

{% youtube id="dQw4w9WgXcQ" title="Never gonna give you up" %}

{% note kind="warning" %}
    Do not feed the llamas after midnight.
{% endnote %}

A shortcode renders with the full context of the page that calls it, plus the keyword arguments from the call. The keyword arguments win when a name appears in both. A paired shortcode also gets inner, its rendered body.

StaticFiles reads the folder once, when you build the app. Five kinds of file name raise a ValueError there: a name that normalizes to nothing, a non-identifier such as 3d.html, a reserved Jinja tag such as for.html, a collision with another tag, and a shadowed end tag such as endnote.html next to note.html.

To use shortcodes outside StaticFiles, build the extension yourself:

from jinja2 import Environment, FileSystemLoader

from starlette_templates.shortcodes import shortcode_extension

env = Environment(
    loader=FileSystemLoader("templates"),
    extensions=[shortcode_extension("shortcodes")],
)

HTML in Python

The ht factory builds Element trees in Python. Use it for email bodies, or for HTML you want to return without a template file:

from starlette_templates import ht

card = ht.div(
    ht.h1("Hello World"),
    ht.p("Built in Python."),
    id="card",
    classes=["container", "content"],
    style={"color": "red"},
)
html = ht.render_element(card)

Document is an Element subclass that renders a full HTML document. It is also an ASGI app, so a handler can return one directly:

from starlette.applications import Starlette
from starlette.routing import Route

from starlette_templates import Document, ht


async def homepage(request):
    return Document(ht.h1("Welcome"), page_title="Home")


app = Starlette(routes=[Route("/", homepage)])

HTMX fragments

When a shopper adds an item to a cart, the badge, the total, and the mini cart all have to change.

A fragment is a template under fragments/. The name of the file is the name of the fragment, and it is also the id of the root element that the template renders. The template lists the events that make it out of date. Here the name is cart_badge:

{# templates/fragments/cart_badge.html.jinja — the name is the stem of the file #}
{% set triggers = ["cart.changed"] -%}
{% sql count %}SELECT count(*) AS n FROM cart{% endsql -%}
<span id="cart_badge" class="badge">{{ fetch_value(queries.count) }}</span>

The fragment renders with request and a QuerySet of its own, so a fragment that displays one query needs no Python.

Those events are called triggers, and a trigger is only a name. cart_badge lists cart.changed, so it re-renders whenever a handler sends that trigger. Several fragments can list the same trigger, and one fragment can list several.

When the template cannot produce the variables it needs, give the fragment an async context function of the same name. It takes the request and returns a dictionary of variables, and it runs on every render:

# store.py — one function per fragment, named after the fragment
async def cart_badge(request):
    return {"count": await request.app.state.cart.count()}


async def cart_total(request):
    return {"total": await request.app.state.cart.total()}
{# templates/fragments/cart_badge.html.jinja #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>

One name, three places. The fragment is named cart_badge because of the file, and the same name is the id htmx swaps and the name of the context function:

templates/fragments/cart_badge.html.jinja  →  the name of the fragment
<span id="cart_badge">                     →  the element htmx swaps
async def cart_badge(request)              →  its context function, when it needs one

A template file may be spelled .html, .jinja, .j2, .html.jinja, or .html.j2, and the whole suffix comes off to leave the name. A template that lists no triggers raises an error when you build the app, because a trigger is the one thing that renders a fragment again. An id that does not match the name raises an error while the fragment renders.

Every template in fragments/ is a fragment, even when you pass no fragments argument. The argument names the context functions of the fragments that need one:

statics = StaticFiles(loader=FileSystemLoader(TEMPLATES), html=True, fragments=store)

Each fragment is a Jinja tag of its own name, so a page drops one in wherever it wants:

<header>
  {% cart_badge %}
  {% cart_total %}
</header>

The handler changes one thing and sends the trigger that says what happened. It names no fragments and no DOM ids:

async def add_to_cart(request):
    await request.app.state.cart.add(request.path_params["sku"])
    return HTMXResponse("Added.", trigger="cart.changed")

HTMXResponse renders every fragment that lists cart.changed. They render at the same time, and each one goes back in the same response as an htmx out-of-band swap:

$ curl -X POST localhost:8000/cart/add/boot
Added.
<span id="cart_badge" class="badge" hx-swap-oob="true">1</span>
<span id="cart_total" class="total" hx-swap-oob="true">$129.00</span>
<ul id="mini_cart" class="mini-cart" hx-swap-oob="true">...</ul>

htmx swaps each one into the element carrying the same id. HTMXResponse finds the fragments through the site you mounted, so you install no middleware and the handler needs no access to the site. A trigger that no fragment lists leaves the response untouched.

Keep a slow fragment off the critical path

A slow fragment delays the response to the click. Add {% set pull = true %} to its template:

{# templates/fragments/recommendations.html.jinja #}
{% set triggers = ["cart.changed"] -%}
{% set pull = true -%}
<div id="recommendations" class="recs">
  {% for item in suggestions %}<li>{{ item.name }}</li>{% endfor %}
</div>

The page still writes {% recommendations %}, and the handler still names no fragments. The tag now emits the markup that re-fetches the fragment from the /fragment/<name> URL that the site already answers:

<div hx-get="/fragment/recommendations" hx-trigger="cart.changed from:body" hx-swap="outerHTML">
  <div id="recommendations" class="recs">...</div>
</div>

The fragment stays out of the response to the click, and the HX-Trigger header survives so the browser asks for it separately. You pay one extra round trip, and the slow fragment no longer delays the click.

Where the context functions come from

Most sites keep them in one module. The fragments argument is optional, and takes any of these, or a sequence that mixes them:

fragments=store  # a module: each function by its own name
fragments={"cart_badge": badge_for_header}  # a mapping: name a fragment something else
fragments=cart_badge  # one function, by its __name__
fragments=[store, checkout, {"mini_cart": mini}]  # any mix of the three

To send a trigger from a response of another class, install HTMXMiddleware. It does the same work for any response that carries an HX-Trigger header, at the cost of buffering it. Install it innermost, before any middleware that rewrites the body, so that layer sees the finished body with the swaps already in it.

The htmx cascade example is a runnable cart with five fragments and two routes, and no handler in it names a fragment.

Next steps

  • Static files — every StaticFiles argument, path resolution, and HTTP caching.
  • Shortcodes — tag names, discovery, and validation.
  • HTMX fragments — the full cascade, pull, and HTMXMiddleware.
  • HTML in Python — what ht accepts as a child, and Document.
  • Error handling — raise AppException to get an HTML page or a JSON:API document.

Release files for starlette-templates 0.0.1a10

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for starlette-templates 0.0.1a10
File Size Uploaded
starlette_templates-0.0.1a10.tar.gz 93.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for starlette-templates 0.0.1a10
File Interpreter ABI Platform
starlette_templates-0.0.1a10-py3-none-any.whl Python 3 none any Details

Total release size:158.3 kB

Release files / starlette_templates-0.0.1a10.tar.gz

Download URL starlette_templates-0.0.1a10.tar.gz
Size 93.3 kB
Tags Source
SHA-256 checksum
How to use checksums
5ff8e0f2d18b98783fa3eb512559d7d4bc44024902c03e7930edba522db09296
BLAKE2b-256 checksum
How to use checksums
d9d09ca055bc39b0fd20c6665da550bd3f6413b59fffa2e0b31f068148606050
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.10

Release files / starlette_templates-0.0.1a10-py3-none-any.whl

Download URL starlette_templates-0.0.1a10-py3-none-any.whl
Size 65.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
94ca5b32ad83e3e5244a9cecc3894a8b66d73c6967ce5df37aef99dd3911e2eb
BLAKE2b-256 checksum
How to use checksums
1df94fcc520c370a232814d9bd504883e64720db30a98410e2ab595aa9569f14
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.10
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page