Build Interactive UIs with FastAPI + HTMX (2026)

Web Development Sep 24, 2026

Watch on YouTube

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") — full index.html for 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-oob counter, 422 validation fragments
  • Security: Jinja2 autoescape on, never echo raw user HTML; protect hx-post with CSRF in real apps

SPA (app.frontend) vs HTMX — when to pick which

ConcernSPA 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 · Jinja2 3.1.6 · uvicorn 0.53.0 · httpx 0.28.1
  • python-multipart 0.0.32 (required for Form(...))
  • 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 plus hx-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.

Share:
Last updated: September 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices