Build Interactive UIs with FastAPI + HTMX (2026)
Build Interactive UIs with FastAPI + HTMX (2026) — a no-build UI stack: FastAPI returns HTML, Jinja2 renders pages and fragments, and HTMX 2.x swaps them into the DOM. You keep Python as the source of truth — no SPA bundler for CRUD screens, live search, or inline validation. This guide is a working task-board demo you can run with uvicorn and assert with FastAPI’s TestClient.
PyInns already covers same-process SPA serving with FastAPI app.frontend(), edge deploy with Cloudflare Python Workers, and streaming tokens with FastAPI SSE. Those stay JSON-first or asset-first. This post is the hypermedia path: full page vs fragment via HX-Request, hx-get/hx-post, live search, out-of-band swaps, and form-error fragments.
TL;DR
- Stack: Python
>=3.13, FastAPI + Jinja2 + HTMX 2.0.10 from a pinned CDN (no npm build for the UI layer) - Detect HTMX with
request.headers.get("HX-Request")— fullindex.htmlfor browsers, fragment templates for swaps - Use current
templates.TemplateResponse(request, "name.html", {...})(request first) - Patterns: live search (
hx-trigger="keyup changed delay:300ms"),hx-swap-oobcounter, 422 validation fragments - Security: Jinja2 autoescape on, never echo raw user HTML; protect
hx-postwith CSRF in real apps
SPA (app.frontend) vs HTMX — when to pick which
| Concern | SPA via app.frontend() | HTMX + Jinja2 |
|---|---|---|
| UI runtime | React/Vue/Svelte (or similar) in the browser | HTML fragments from the server |
| Build step | Yes (Vite/webpack/…) | No UI bundler required |
| State | Client store + JSON APIs | Server templates + forms |
| Best fit | Rich client apps, offline-ish UX, design systems already in JS | CRUD dashboards, admin tools, content apps, progressive enhancement |
| PyInns guide | app.frontend() SPA |
This post |
Pick the SPA path when your product already is a client app. Pick HTMX when most screens are forms, tables, and filters — and you want one Python process to own the HTML.
Versions tested (2026-09-24)
- Python
3.13.5 - FastAPI
0.141.1· Jinja23.1.6· uvicorn0.53.0· httpx0.28.1 python-multipart0.0.32(required forForm(...))- HTMX
2.0.10—https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
Official references: HTMX docs and FastAPI templates.
Project layout
demo/
├── app.py
├── test_app.py
├── .venv/
└── templates/
├── base.html
├── index.html
└── partials/
├── task_form.html
├── task_list.html
├── task_panel.html
├── task_list_with_oob.html
└── form_errors.html
python3 -m venv .venv
source .venv/bin/activate
pip install fastapi jinja2 uvicorn httpx python-multipart
uvicorn app:app --reload
# other terminal
python test_app.py
1. App shell — Jinja2 + HX-Request
Jinja2Templates points at templates/. A tiny helper reads the HTMX header. Full document for normal browsers; list fragment when HTMX asks.
# app.py (excerpt)
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
BASE = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
app = FastAPI(title="PyInns FastAPI + HTMX demo")
TASKS = [
{"id": 1, "title": "Write FastAPI routes", "done": True},
{"id": 2, "title": "Wire HTMX attributes", "done": False},
{"id": 3, "title": "Add live search", "done": False},
]
_next_id = 4
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request", "").lower() == "true"
def filtered_tasks(q: str | None) -> list[dict]:
if not q:
return list(TASKS)
needle = q.strip().lower()
return [t for t in TASKS if needle in t["title"].lower()]
def render(request: Request, name: str, context: dict, status_code: int = 200):
# Current Starlette/FastAPI signature: request first, then template name
return templates.TemplateResponse(request, name, context, status_code=status_code)
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, q: str | None = None):
ctx = {"tasks": filtered_tasks(q), "q": q or "", "count": len(TASKS),
"errors": {}, "form_title": ""}
if is_htmx(request):
return render(request, "partials/task_list.html", ctx)
return render(request, "index.html", ctx)
Do not use the old TemplateResponse("name.html", {"request": request, ...}) keyword style in new code — the installed FastAPI/Starlette API is TemplateResponse(request, name, context).
2. Base template — pin HTMX 2.x
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PyInns FastAPI + HTMX demo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"
integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"
crossorigin="anonymous"></script>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
Pin the version (@2.0.10) and prefer Subresource Integrity in production. Unpkg and jsDelivr both carry the same release; this demo uses jsDelivr’s documented integrity hash.
3. Live search — hx-get + delayed keyup
The search box issues a GET to /tasks after the user pauses typing. Only #task-list is replaced (hx-swap="innerHTML").
<!-- inside templates/index.html -->
<p class="meta">Open tasks: <span id="task-count">{{ count }}</span></p>
<input id="search" type="text" name="q" value="{{ q }}"
placeholder="Filter tasks…"
hx-get="/tasks"
hx-trigger="keyup changed delay:300ms"
hx-target="#task-list"
hx-swap="innerHTML"
hx-include="this">
<div id="task-panel">{% include "partials/task_panel.html" %}</div>
@app.get("/tasks", response_class=HTMLResponse)
async def tasks_partial(request: Request, q: str | None = None):
ctx = {"tasks": filtered_tasks(q), "q": q or "", "count": len(TASKS)}
return render(request, "partials/task_list.html", ctx)
hx-trigger="keyup changed delay:300ms" debounces the request so you do not hit the server on every keystroke. The endpoint returns a bare <ul>…</ul> fragment — not a full HTML document.
4. Create / toggle / delete — hx-post + panel swap
Forms post to FastAPI and retarget #task-panel so the form and list refresh together.
<!-- partials/task_form.html -->
<form id="add-form"
hx-post="/tasks"
hx-target="#task-panel"
hx-swap="innerHTML">
<input type="text" name="title" value="{{ form_title or '' }}"
placeholder="New task title">
<button type="submit">Add</button>
{% if errors and errors.get('title') %}
<p class="error" id="title-error">{{ errors.title }}</p>
{% endif %}
</form>
@app.post("/tasks", response_class=HTMLResponse)
async def create_task(request: Request, title: str = Form("")):
global _next_id
title = (title or "").strip()
errors: dict[str, str] = {}
if not title:
errors["title"] = "Title is required."
elif len(title) < 3:
errors["title"] = "Title must be at least 3 characters."
elif any(t["title"].lower() == title.lower() for t in TASKS):
errors["title"] = "That task already exists."
if errors:
ctx = {"tasks": list(TASKS), "q": "", "count": len(TASKS),
"errors": errors, "form_title": title}
return render(request, "partials/form_errors.html", ctx, status_code=422)
TASKS.append({"id": _next_id, "title": title, "done": False})
_next_id += 1
ctx = {"tasks": list(TASKS), "q": "", "count": len(TASKS),
"errors": {}, "form_title": "", "oob_count": True}
return render(request, "partials/task_list_with_oob.html", ctx)
5. Out-of-band swap — keep the counter in sync
The counter lives outside #task-panel. After a successful create/toggle/delete, include a second element marked hx-swap-oob="true" so HTMX updates #task-count without changing the primary swap target.
<!-- partials/task_list_with_oob.html (tail) -->
{% if oob_count %}
<span id="task-count" hx-swap-oob="true">{{ count }}</span>
{% endif %}
TestClient output after posting a fourth task (real run on this draft’s demo):
<span id="task-count" hx-swap-oob="true">4</span>
6. What the browser vs HTMX actually receives
Normal GET / returns the full document (DOCTYPE, HTMX script, shell). The same path with HX-Request: true returns only the list fragment. Captured from TestClient:
# GET / (no HX-Request) — starts with:
<!DOCTYPE html>
<html lang="en">
<head>
...
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"
integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"
crossorigin="anonymous"></script>
# GET / (HX-Request: true) — fragment only, no <html>:
<ul>
<li class="row" id="task-1">...Write FastAPI routes...</li>
...
</ul>
# GET /tasks?q=live — filtered fragment contains only "Add live search"
# POST /tasks title= — HTTP 422 fragment includes:
<p class="error" id="title-error">Title is required.</p>
7. Smoke tests with TestClient
# test_app.py (excerpt)
from fastapi.testclient import TestClient
import app as demo
demo.reset_store()
client = TestClient(demo.app)
r = client.get("/")
assert "<html" in r.text.lower()
assert "htmx.org@2.0.10" in r.text
r = client.get("/", headers={"HX-Request": "true"})
assert "<html" not in r.text.lower()
assert "Write FastAPI routes" in r.text
r = client.get("/tasks", params={"q": "live"}, headers={"HX-Request": "true"})
assert "Add live search" in r.text
assert "Write FastAPI routes" not in r.text
r = client.post("/tasks", data={"title": ""}, headers={"HX-Request": "true"})
assert r.status_code == 422
assert "Title is required" in r.text
r = client.post("/tasks", data={"title": "Ship the tutorial"},
headers={"HX-Request": "true"})
assert 'hx-swap-oob="true"' in r.text
assert ">4</span>" in r.text or 'hx-swap-oob="true">4<' in r.text
Run python test_app.py in the demo directory — the script prints the same full-page / fragment / search / 422 / OOB snippets shown above.
Security notes (short, practical)
- Autoescape: Jinja2Templates enable autoescaping for HTML by default. Keep it on. Render user titles with
{{ task.title }}, never{{ task.title | safe }}unless you have sanitized HTML yourself. - Do not return raw user HTML: Treat every fragment as a template with escaped variables. If you need rich text later, sanitize on the server with an allow-list library — do not pipe request bodies into the response.
- CSRF for
hx-post: Cookie-authenticated apps should issue a CSRF token (double-submit cookie or synchronizer token) and include it on every mutating HTMX form — e.g. a hidden input plushx-headers='{"X-CSRFToken": "..."}', verified in a FastAPI dependency. This demo uses an in-memory store and skips auth on purpose; add CSRF before any real session cookie.
Wrap-up
FastAPI + Jinja2 + HTMX 2.x is a practical 2026 no-build UI stack: one process serves full pages and HTML fragments, live search is a debounced hx-get, counters stay fresh with hx-swap-oob, and validation errors come back as 422 fragments. Use app.frontend() when you truly need a SPA, ship the same API patterns to the edge with Cloudflare Python Workers, and stream model output with FastAPI SSE when the UI is chat — not forms.