ProvenPin Install

FastAPI Jinja2 full pages vs HTMX partials via HX-Request: working versions (tested 2026-10-15)

Serve a full HTML page to normal browsers and a bare HTML fragment to HTMX requests, chosen by the HX-Request header, with Vary set for caches. Tested offline with the sync TestClient.

passed6 passed · 0 failed2026-10-15 13:21 UTC
Status
passed
Last passed
2026-10-15 13:21 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions4
fastapi@0.143.1jinja2@3.1.6httpx@0.28.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38741369695

The code

Exactly the files that passed. Your agent can pull them with get_recipe("fastapi-jinja2-htmx-partials") (install), or see them on GitHub.

requirements.txt
fastapi==0.143.1
jinja2==3.1.6
httpx==0.28.1
pytest==9.1.1
app.py
from pathlib import Path
from typing import Annotated

from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
app = FastAPI()
ITEMS = [{"id": 1, "name": "apples"}, {"id": 2, "name": "pears"}]


def is_htmx(request: Request) -> bool:
    # HTMX sends the literal string "true"; check the value, not just presence
    return request.headers.get("HX-Request") == "true"


def render(request: Request, page: str, partial: str, context: dict) -> HTMLResponse:
    name = partial if is_htmx(request) else page
    response = templates.TemplateResponse(request, name, context)
    # Without this, a shared cache can serve a partial to a full-page request
    response.headers["Vary"] = "HX-Request"
    return response


@app.get("/items", response_class=HTMLResponse)
def list_items(request: Request):
    return render(request, "items_page.html", "items_partial.html", {"items": ITEMS})


@app.post("/items", response_class=HTMLResponse)
def add_item(request: Request, name: Annotated[str, Query(min_length=1)]):
    item = {"id": len(ITEMS) + 1, "name": name}
    ITEMS.append(item)
    return templates.TemplateResponse(request, "item_row.html", {"item": item})
templates/base.html
<!doctype html>
<html>
<head>
  <title>{% block title %}Items{% endblock %}</title>
  <script src="https://unpkg.com/htmx.org"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
templates/items_page.html
{% extends "base.html" %}
{% block content %}
{% include "items_partial.html" %}
{% endblock %}
templates/items_partial.html
<ul id="items">
{% for item in items %}
  <li>{{ item.name }}</li>
{% endfor %}
</ul>
templates/item_row.html
<li>{{ item.name }}</li>
tests/test_pages.py
import pytest
from fastapi.testclient import TestClient

from app import ITEMS, app


@pytest.fixture
def client():
    ITEMS[:] = [{"id": 1, "name": "apples"}, {"id": 2, "name": "pears"}]
    with TestClient(app) as c:
        yield c


def test_browser_gets_full_page(client):
    r = client.get("/items")
    assert r.status_code == 200
    assert "<!doctype html>" in r.text
    assert "<html>" in r.text
    assert '<ul id="items">' in r.text


def test_htmx_gets_partial_only(client):
    r = client.get("/items", headers={"HX-Request": "true"})
    assert "<html" not in r.text
    assert '<ul id="items">' in r.text
    assert r.text.count("<li>") == 2


def test_vary_header_on_both_responses(client):
    # Caches must key on HX-Request or they may hand a fragment to a browser
    assert "HX-Request" in client.get("/items").headers["vary"]
    assert "HX-Request" in client.get("/items", headers={"HX-Request": "true"}).headers["vary"]


def test_header_value_must_be_true(client):
    # Presence alone is not enough: "false" should still get the full page
    r = client.get("/items", headers={"HX-Request": "false"})
    assert "<html>" in r.text


def test_partial_output_is_autoescaped(client):
    r = client.post("/items", params={"name": "<b>x</b>"})
    assert "&lt;b&gt;x&lt;/b&gt;" in r.text
    assert "<b>x</b>" not in r.text


def test_empty_name_rejected(client):
    r = client.post("/items", params={"name": ""})
    assert r.status_code == 422
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.