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 Jinja templates, Markdown, and static files. Templates in shortcodes/ become Jinja tags and templates in fragments/ become HTMX fragments that re-render themselves when a handler reports a change.

pip install starlette-templates

Quick start

This site holds one page, one shortcode, and one fragment:

app.py
templates/
    index.jinja
    shortcodes/
        note.jinja
    fragments/
        clock.jinja

app.py mounts the site and registers two functions: one that supplies the data of the fragment, and one that handles the button:

from datetime import datetime
from importlib.resources import files

from jinja2 import FileSystemLoader
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.requests import Request

from starlette_templates import HTMXResponse, StaticFiles

PKG_DIR = files(__package__)

async def clock(request: Request) -> dict:
    """the context function of the clock fragment"""
    return {"now": datetime.now().strftime("%H:%M:%S")}

async def refresh(request: Request) -> HTMXResponse:
    """the handler of the refresh button"""
    return HTMXResponse("Refreshed.", trigger="clock.tick")

app = Starlette(
    routes=[
        Route("/refresh", refresh, methods=["POST"]),
        # The catch-all mount goes last.
        Mount(
            "/",
            StaticFiles(
                loader=FileSystemLoader(PKG_DIR / "templates"),
                html=True,
                fragments=[clock],
            ),
            name="site",
        ),
    ]
)

fragments takes context functions: async functions that take the request and return the variables for the Jinja template of the same name. They run each time the fragment renders, so the fragment always shows current data.

templates/fragments/clock.jinja renders the fragment. Its first line lists the triggers that re-render it:

{% set triggers = ["clock.tick"] -%}
<span id="clock">{{ now }}</span>

Triggers are named events. A template lists the triggers that re-render it, and a handler sends one with HTMXResponse(..., trigger=...). The name is the only coupling between the two: the handler names no fragment and no DOM id.

One name, three places. The fragment is named clock because the function is named clock. The template must be fragments/clock.jinja, and the root element it renders must carry id="clock":

async def clock(request)          →  the name of the fragment
templates/fragments/clock.jinja   →  the template of that name
<span id="clock">                 →  the element htmx swaps

The id is what htmx replaces on the page. A mismatched id raises when the fragment renders, rather than swapping into nothing.

templates/shortcodes/note.jinja is a snippet. It uses inner, so the tag takes a body:

<div class="note note-{{ kind }}">{{ inner }}</div>

templates/index.jinja calls both of them as tags:

<!DOCTYPE html>
<html>
<head><script src="https://unpkg.com/htmx.org@2"></script></head>
<body>
  <p>The time is {% clock %}.</p>

  {% note kind="tip" %}
    The button below never names the clock.
  {% endnote %}

  <button hx-post="/refresh" hx-target="#flash">Refresh</button>
  <div id="flash"></div>
</body>
</html>

Run it with uvicorn app:app --reload and open http://localhost:8000/. A click posts to refresh, which answers with Refreshed. and sends clock.tick. The clock lists that trigger, so it renders and rides back in the same response:

$ curl -X POST localhost:8000/refresh
Refreshed.<span id="clock" hx-swap-oob="true">14:32:07</span>

htmx swaps that span into the element with the same id, so the clock updates although the handler never named it. To add a second fragment, you write another template and another function.

Static files

StaticFiles is a raw ASGI app that serves the directories behind a Jinja2 loader. A .jinja or .j2 file renders as a template, and every other file is served unchanged with ETag and Last-Modified support.

Only loader is required. The call below passes everything it takes:

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 directory URL serves the index page from that directory, and a directory URL without a trailing slash redirects to add one. A request that matches no file falls back to a 404 page when one exists.

Extensionless URLs work as well. A page may be spelled .jinja, .j2, .html, .html.jinja, or .html.j2, and a request for /about finds any of those spellings.

Template globals

Every rendered template has these, and so does Markdown rendered through include_markdown():

{# 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') }}).

  {% note kind="tip" %}Shortcodes work here too.{% endnote %}

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

The body is Jinja first and Markdown second, so variables, filters, shortcodes, and fragment tags all work in it. The block strips its own indentation before the conversion, so a block laid out 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

A page often needs data of its own. Each route in page_context names a URL and an async function, and the function returns the variables that the page at that URL renders with:

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 contributes, in declaration order, so a later route wins a repeated name. Nothing calls the function as an endpoint: StaticFiles uses the route for its path alone. Every shortcode the page calls reads these variables too, and so does every {% markdown %} block and every Markdown file the page includes.

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 three helpers differ in what they return:

{{ 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.

The query string of the request supplies the :name placeholders, so ?country=US supplies :country. The runner binds them out of band and never interpolates them.

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 the connection, which this one ignores. Without a runner, fetch() returns an empty list, so a site with no database still serves.

Shortcodes

A shortcodes/ folder under a served directory needs no registration. Every template file in it becomes a Jinja tag named after the stem of the file, slugified with underscores:

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

The template decides the form of the tag. 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 template renders with the keyword arguments from the call site, which win over the calling context. It also renders with the full context of the calling page. A paired shortcode gets inner as well, the rendered body.

Discovery happens once, when you build the app. A file whose name cannot become a usable tag raises a ValueError instead of being skipped: 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, or 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")],
)

HTMX fragments

One click often makes several parts of a page wrong at once. When a shopper adds an item to a cart, the badge, the total, and the mini cart all have to change.

A fragment is three things that share one name: an async context function, a template under fragments/ of that name, and the id of the root element that the template renders. Here that name is cart_badge:

# 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 — the name is the stem of the file #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>

Triggers are named events. 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.

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 with no context function of its name, and a template that lists no triggers, both raise when you build the app. An id that does not match the name raises when the fragment renders.

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

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

<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 concurrently, and each rides 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>

The response finds the fragments through the site mounted in the app, so you install no middleware and the site hands nothing to the handler. A trigger that no fragment lists leaves the response untouched.

A slow fragment fetches itself

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>

Nothing else changes. 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 an extra round trip and keep the slow fragment off the critical path.

Where the context functions come from

A module is the common source. The fragments argument 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.

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

HTML in Python

The ht factory builds Element trees in Python. Use it for fragments, email bodies, or 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)])

Release files for starlette-templates 0.0.1a9

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.1a9
File Size Uploaded
starlette_templates-0.0.1a9.tar.gz 92.2 kB Details

Built distribution (wheel)

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

Total release size:156.3 kB

Release files / starlette_templates-0.0.1a9.tar.gz

Download URL starlette_templates-0.0.1a9.tar.gz
Size 92.2 kB
Tags Source
SHA-256 checksum
How to use checksums
a71a805a9beb21252f4beafff83abff1b451fe1e7b4709ec4cdf2722a95c22eb
BLAKE2b-256 checksum
How to use checksums
81c84930e01b2f5f1f331c385a63e68dbaa0e4272f93898a7216ec0d2916da83
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.1a9-py3-none-any.whl

Download URL starlette_templates-0.0.1a9-py3-none-any.whl
Size 64.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bdf36a5a8f26dd63d5c7ee5f413d9a6f1893229c5ada4f2ad47b260ab0186836
BLAKE2b-256 checksum
How to use checksums
a1d762a911d3606c6a308dd16b66bd2d426962552e203cdaa4e8cf292dd625d8
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