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:29 UTC- Status
- passed
- Last passed
- 2026-10-15 13:29 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
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.
fastapi==0.143.1 jinja2==3.1.6 httpx==0.28.1 pytest==9.1.1
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})
<!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>
{% extends "base.html" %}
{% block content %}
{% include "items_partial.html" %}
{% endblock %}
<ul id="items">
{% for item in items %}
<li>{{ item.name }}</li>
{% endfor %}
</ul>
<li>{{ item.name }}</li>
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 "<b>x</b>" 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] 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.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
FastAPI lifespan state tested with httpx ASGITransport and asgi-lifespan
A FastAPI app that opens a SQLite connection in its lifespan, then test it offline through httpx ASGITransport, with asgi-lifespan running startup and shutdown.