HTMX has crossed the threshold from cult favorite to mainstream tool. The library now sits at 47,920 GitHub stars – a milestone Reptile.haus’s July 2026 roundup independently corroborated, putting the count past 47,000 stars – ships 152,014 weekly NPM downloads, and was named the most admired front-end tool in the State of JS 2024 results published in February 2026. The HTMX 2.0.10 release landed on April 21, 2026, fixing TypeScript type definitions and an escaping bug per the project’s own GitHub changelog; Dplooy’s full-stack htmx tutorial was still pinning that same 2.0.10 build as stable alongside 4.0.0-beta6 as of July 28, 2026. That run of betas has since shipped: the htmx GitHub releases page tagged v4.0.0 as “Latest” on August 28, 2026, the project’s 34th tracked release, swapping XHR for the Fetch API for good – the culmination of roughly eight months of development, and a release that skipped version 3 entirely by jumping straight from the 2.0 line introduced in 2024 to 4.0, according to InfoQ’s September 2026 coverage.
This htmx tutorial walks through 13 production-grade steps to build a complete real-time task manager using HTMX 2.0.9, Flask 3.0.3, SQLite, and Server-Sent Events – the same stack Tech Insider’s own “HTMX Tutorial: Build a Live Web App in 13 Steps” pinned in April 2026, alongside Gunicorn 22.0.0 and a Python 3.12.7 baseline runtime. By the end you will have a deployable web app with live search, inline edits, optimistic UI, and toast notifications – all without a single line of custom JavaScript framework code. Total build time: roughly 90 minutes.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why HTMX Won the 2026 Hypermedia Comeback
HTMX extends HTML with a small set of attributes – hx-get, hx-post, hx-swap, hx-target, hx-trigger – that let any element issue AJAX requests and swap the response directly into the DOM; FoxLearn’s beginner-focused “Getting Started with HTMX” tutorial, published February 7, 2025, distills the whole model down to just two core concepts for newcomers. There is no virtual DOM, no JSX, no client-side router, and no build step. The browser does what browsers have always done: ask the server for HTML and put it on the page. The library has grown a bit since its early 14 KB days: Dplooy’s full-stack htmx tutorial measured the 2.0.10 minified bundle at 51 KB on disk and roughly 16 KB after gzip straight off jsDelivr in July 2026 – still a different weight class than React’s typical 40–130 KB bundle once react-dom and the runtime are included.
Why it matters in 2026: the State of JS 2024 survey, published in February 2026, ranked HTMX as the most admired library among respondents for the second year running, while React’s overall satisfaction hit its lowest mark since the survey began despite holding 83.6% usage in that same February 2026 report. Carson Gross, the creator who started HTMX as a fork of intercooler.js back in November 2020, has declared the 2.x line feature-complete – reinforced by Woodruff.dev’s coverage of the 2.0.0 release, which added 36 new tests to bring the suite to 1,838 tests by April 2026 – and that discipline carried into 4.0: the build tagged “Latest” on GitHub as v4.0.0 on August 28, 2026 ships Fetch API streaming, Idiomorph DOM merging, and an hx-status swap selector for HTTP-aware behavior. Teams moving cautiously can relax: per htmx’s own August 2026 release notes, npm still marks v4.0.0 with the next dist-tag while the 2.x line keeps the latest tag until htmx marks 4.0 as latest at some point in 2027, so nothing installs the new major by accident. The release also standardized event names. None of those changes break the mental model you will learn here.
The shift is not just technical. Hiring managers report fewer candidates demanding a React-only stack, conference talks now openly discuss “post-SPA” architecture, and even Drupal – one of the largest CMS communities in the world – integrated HTMX directly into Drupal 12.x core during 2025, replacing dozens of legacy AJAX widgets. W3Techs’ crawl put HTMX on 0.1% of all websites it tracks as of August 8, 2026 – a modest-sounding share that still adds up to millions of production sites – and found that 48.2% of those HTMX sites had already moved onto the 2.x branch. The library has graduated from a curiosity that made the rounds on Hacker News into a tool with line-of-business deployments at Fortune 500 companies. Your CTO has likely seen the slides.
Prerequisites and Stack Versions
This htmx tutorial pairs HTMX with Flask because Python is the most common backend in the HTMX community and Flask’s response model maps cleanly to HTML fragments. Every concept transfers to Django, FastAPI, Go’s Gin, Rust’s Axum, Node.js Express, or Ruby on Rails – HTMX is server-agnostic by design.

| Component | Version | Purpose | Why This Version |
|---|---|---|---|
| Python | 3.12 or 3.13 | Backend runtime | 3.13 ships free-threaded GIL preview; 3.12 is LTS-stable |
| HTMX | 2.0.9 | Hypermedia engine | Stable as of April 20, 2026; npm listed 2.0.10 as current by August 2026 |
| Flask | 3.0.3 | HTTP server & templating | Stable Werkzeug 3 & Jinja 3.1.x |
| SQLite | 3.45+ | Embedded database | Ships with Python; zero setup |
| Tailwind CSS | 3.4 (CDN) | Styling | CDN keeps the demo build-step-free |
| Hyperscript | 0.9.13 | Inline scripting | Optional companion to HTMX |
| Gunicorn | 22.0+ | Production WSGI server | Required for SSE workers |
You also need a terminal, any modern browser (Chrome 120+, Firefox 122+, Safari 17+, Edge 120+ – HTMX requires the Fetch API, which has been universal since 2017), and a text editor. The full project clocks in under 250 lines of Python and 180 lines of Jinja templates.
Step 1: Install Python 3.12 and Bootstrap the Project
Confirm Python is on your path, then carve out an isolated virtual environment so you do not pollute your global site-packages. The HTMX library itself ships from a CDN, so there is nothing to install from npm.
# Verify Python is at least 3.12
python3 --version
# Python 3.12.7
# Create a project directory and virtual environment
mkdir htmx-tasks && cd htmx-tasks
python3 -m venv .venv
source .venv/bin/activate # Windows: .venvScriptsactivate
# Pin Flask 3.0.3 and Gunicorn 22.0
pip install "Flask==3.0.3" "Flask-WTF==1.2.1" "gunicorn==22.0.0"
pip freeze > requirements.txt
Flask-WTF is included up front because Step 13 wires CSRF protection into every HTMX request. Skipping it now and grafting it on later forces you to refactor every form. The requirements.txt lockfile pins versions so deployments are reproducible.
Step 2: Lay Out the Flask Project Structure
HTMX projects work best with one route returning a full page and a fleet of routes returning small HTML fragments. Mirror that split in the directory layout so you can find fragments at a glance six months from now – it’s the same lean skeleton Marcus Hammarberg builds from scratch in his January 2025 “HTMx – back to basics” walkthrough on marcusoft.net, which sets up a single htmx-todo-tutorial app as the foundation for the rest of his series.
htmx-tasks/
├── app.py # Flask application + routes
├── db.py # SQLite helpers
├── requirements.txt
├── tasks.db # Created at first run
├── static/
│ └── styles.css # Optional overrides
└── templates/
├── base.html # Shell with HTMX script tag
├── index.html # Full-page view
└── partials/
├── task_list.html # Fragment: full list
├── task_row.html # Fragment: single task
└── toast.html # Fragment: notification
The partials/ folder is the cornerstone of the architecture. Every HTMX endpoint renders a single partial. When you read a route handler later, it is obvious whether the response targets the whole page (index.html) or a swap target (partials/task_row.html).
Step 3: Create the SQLite Schema and Connection Helper
SQLite needs no server, no auth, no port. The whole database lives in a single file. For a production HTMX app at moderate scale you can absolutely keep SQLite – it handles thousands of concurrent reads with WAL mode enabled.
# db.py
import sqlite3
from contextlib import contextmanager
from flask import g
DB_PATH = "tasks.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tasks_title ON tasks(title);
"""
def get_db():
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA journal_mode=WAL")
return g.db
def close_db(e=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
with sqlite3.connect(DB_PATH) as conn:
conn.executescript(SCHEMA)
The PRAGMA journal_mode=WAL line is critical: write-ahead logging lets readers and writers operate in parallel, which matters once you add SSE in Step 10. Without WAL the SSE stream will block every other request.
Step 4: Build the Base Template With HTMX 2.0.9 Loaded
HTMX is shipped as a single script tag. Pin the exact version – never use @latest in production, because a minor release can change default swap timing or extension behavior and break your app silently. Contentful’s own htmx onboarding tutorial, published in June 2025, still pins the older [email protected] CDN build for exactly this reason, and teams that shipped against it have had no surprises even though npm listed [email protected] as the current release by August 2026.

<!-- templates/base.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}HTMX Tasks{% endblock %}</title>
<!-- HTMX 2.0.9 with SRI hash -->
<script src="https://unpkg.com/[email protected]/dist/htmx.min.js"
integrity="sha384-..."
crossorigin="anonymous"></script>
<!-- Optional: hyperscript for inline interactivity -->
<script src="https://unpkg.com/[email protected]"></script>
<!-- Tailwind CDN keeps the tutorial build-step-free -->
<script src="https://cdn.tailwindcss.com"></script>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body class="bg-slate-50 text-slate-900"
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'>
{% block body %}{% endblock %}
<div id="toast" class="fixed bottom-4 right-4"></div>
</body>
</html>
The hx-headers attribute on <body> is inherited by every descendant element, so every HTMX request automatically carries the CSRF token. This single line replaces the dozen data-csrf-token hooks you would write in a React app.
Step 5: Render the Full Page and Your First HTMX Endpoint
Time for the first request. GET / returns the full page; GET /tasks returns just the list fragment. The page is bookmark-friendly and crawler-friendly because the full HTML renders server-side – one of HTMX’s biggest wins over an SPA.
# app.py
from flask import Flask, render_template, request, abort
from flask_wtf.csrf import CSRFProtect
from db import get_db, close_db, init_db
app = Flask(__name__)
app.config["SECRET_KEY"] = "change-me-in-production"
csrf = CSRFProtect(app)
app.teardown_appcontext(close_db)
with app.app_context():
init_db()
@app.get("/")
def index():
rows = get_db().execute(
"SELECT * FROM tasks ORDER BY created DESC"
).fetchall()
return render_template("index.html", tasks=rows)
@app.get("/tasks")
def task_list():
q = request.args.get("q", "").strip()
sql = "SELECT * FROM tasks WHERE title LIKE ? ORDER BY created DESC"
rows = get_db().execute(sql, (f"%{q}%",)).fetchall()
return render_template("partials/task_list.html", tasks=rows)
Notice how task_list returns a partial instead of JSON. That is the HTMX inversion: the server, not the client, decides what HTML belongs in the DOM. You keep your data layer, your validation, and your auth logic in one language – no parallel TypeScript schema, no tRPC, no GraphQL resolvers.
Step 6: Add Live Search With hx-trigger=”keyup changed delay:300ms”
Live search is the canonical HTMX demo because it shows off the framework’s superpower in three lines of HTML. The input listens for keyup events, debounces them by 300 ms, fires GET /tasks?q=..., and swaps the response into #task-list.
<!-- templates/index.html -->
{% extends "base.html" %}
{% block body %}
<main class="max-w-2xl mx-auto p-6">
<h1 class="text-3xl font-bold mb-4">HTMX Tasks</h1>
<input
type="search"
name="q"
placeholder="Search tasks..."
class="w-full p-2 border rounded mb-4"
hx-get="/tasks"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#task-list"
hx-indicator="#spinner">
<span id="spinner" class="htmx-indicator">Searching…</span>
<div id="task-list">
{% include "partials/task_list.html" %}
</div>
</main>
{% endblock %}
The hx-indicator attribute references an element that becomes visible during in-flight requests. HTMX adds the class htmx-request to elements automatically, and .htmx-indicator is styled to opacity:0 by default and fades in when its sibling has htmx-request. This is built-in – no extra script.
Common Pitfall: Missing form tag swallows the keyup
If you wrap the search input in a <form>, pressing Enter triggers a full-page submit instead of an HTMX request. Either skip the form, set hx-trigger="submit" on the form itself, or add hx-on::submit="event.preventDefault()" as a guard.
Step 7: Build the Inline Create Form With hx-post and hx-swap=”afterbegin”
Adding a task should feel instant. We render a tiny form, post the new title to /tasks, receive back a single <li> fragment, and prepend it to the list with hx-swap="afterbegin". The form clears itself with hx-on::after-request – validation is exactly where Marcus Hammarberg takes his own todo build next, extending the same marcusoft.net series to a fifth installment, “HTMx – tutorial part V – Validation,” by January 2025.
<!-- partials/task_list.html -->
<form hx-post="/tasks"
hx-target="#task-items"
hx-swap="afterbegin"
hx-on::after-request="if(event.detail.successful) this.reset()"
class="flex gap-2 mb-4">
<input name="title" required minlength="1" maxlength="200"
class="flex-1 p-2 border rounded"
placeholder="What needs doing?">
<button class="px-4 py-2 bg-blue-600 text-white rounded">
Add
</button>
</form>
<ul id="task-items" class="space-y-2">
{% for t in tasks %}
{% include "partials/task_row.html" %}
{% endfor %}
</ul>
# app.py (continued)
@app.post("/tasks")
def create_task():
title = (request.form.get("title") or "").strip()
if not title:
abort(400, "Title is required")
db = get_db()
cur = db.execute(
"INSERT INTO tasks(title) VALUES (?)", (title,)
)
db.commit()
task = db.execute(
"SELECT * FROM tasks WHERE id=?", (cur.lastrowid,)
).fetchone()
response = render_template("partials/task_row.html", t=task)
return response, 201, {"HX-Trigger": "task-created"}
The HX-Trigger response header fires a custom DOM event named task-created on the body, which the toast listener will pick up in Step 12. Returning 201 Created instead of 200 keeps the API semantically correct – HTMX swaps the body of any 2xx response by default.
Step 8: Implement Click-to-Edit With hx-put and an Editable Fragment
Click-to-edit is HTMX’s killer pattern. The row renders as plain text; clicking a pencil icon swaps in a tiny edit form; submitting it issues a PUT and swaps the row back to display mode. Three endpoints, zero state machines.

<!-- partials/task_row.html -->
<li id="task-{{ t.id }}"
class="flex items-center justify-between p-3 bg-white border rounded">
<span class="{% if t.done %}line-through text-slate-400{% endif %}">
{{ t.title }}
</span>
<div class="flex gap-2">
<button hx-get="/tasks/{{ t.id }}/edit"
hx-target="#task-{{ t.id }}"
hx-swap="outerHTML"
class="text-blue-600">Edit</button>
<button hx-post="/tasks/{{ t.id }}/toggle"
hx-target="#task-{{ t.id }}"
hx-swap="outerHTML"
class="text-green-600">
{% if t.done %}Undo{% else %}Done{% endif %}
</button>
<button hx-delete="/tasks/{{ t.id }}"
hx-target="#task-{{ t.id }}"
hx-swap="outerHTML swap:300ms"
hx-confirm="Delete this task?"
class="text-red-600">Delete</button>
</div>
</li>
# app.py (continued)
@app.get("/tasks/<int:tid>/edit")
def task_edit_form(tid):
t = get_db().execute(
"SELECT * FROM tasks WHERE id=?", (tid,)
).fetchone() or abort(404)
return render_template("partials/task_edit.html", t=t)
@app.put("/tasks/<int:tid>")
def task_update(tid):
title = (request.form.get("title") or "").strip()
if not title:
abort(400)
db = get_db()
db.execute("UPDATE tasks SET title=? WHERE id=?", (title, tid))
db.commit()
t = db.execute(
"SELECT * FROM tasks WHERE id=?", (tid,)
).fetchone()
return render_template("partials/task_row.html", t=t)
@app.post("/tasks/<int:tid>/toggle")
def task_toggle(tid):
db = get_db()
db.execute(
"UPDATE tasks SET done = 1 - done WHERE id=?", (tid,)
)
db.commit()
t = db.execute(
"SELECT * FROM tasks WHERE id=?", (tid,)
).fetchone() or abort(404)
return render_template("partials/task_row.html", t=t)
The swap:300ms modifier on the delete button delays the DOM swap by 300 ms, which lets you animate the row out with a CSS transition before it disappears. Combine it with hx-on::before-swap="this.classList.add('opacity-0')" for a full fade.
Step 9: Click-to-Delete With hx-delete, hx-confirm, and Animated Removal
The DELETE handler is the simplest endpoint in the codebase: remove the row, return an empty body. HTMX swaps an empty string into the target, so the <li> disappears. The browser’s native confirm dialog fires before the request because of hx-confirm.
@app.delete("/tasks/<int:tid>")
def task_delete(tid):
db = get_db()
cur = db.execute("DELETE FROM tasks WHERE id=?", (tid,))
db.commit()
if cur.rowcount == 0:
abort(404)
# Empty 200 response triggers HTMX to remove the target element
return "", 200, {"HX-Trigger": "task-deleted"}
If you prefer a styled confirm modal instead of window.confirm(), listen for the htmx:confirm event globally, prevent the default, render your own dialog, and call event.detail.issueRequest() when the user accepts. The official HTMX documentation includes a SweetAlert2 example you can lift verbatim.
Step 10: Stream Real-Time Updates With Server-Sent Events
Server-Sent Events (SSE) is HTMX’s preferred channel for server-pushed updates. WebSockets work too, but SSE is unidirectional, runs over plain HTTP/2, and reconnects automatically. The official htmx-ext-sse extension – one of dozens listed at htmx.org/extensions – wires it up with two attributes.
<!-- Add to base.html below the htmx script -->
<script src="https://unpkg.com/[email protected]"></script>
<!-- In index.html, wrap the task list -->
<div hx-ext="sse"
sse-connect="/events"
sse-swap="task-update"
hx-target="#task-list"
hx-swap="innerHTML">
{% include "partials/task_list.html" %}
</div>
# app.py (continued)
import time, queue
from flask import Response, stream_with_context
subscribers: list[queue.Queue] = []
def broadcast(event_name: str, data: str):
for q in list(subscribers):
try:
q.put_nowait((event_name, data))
except queue.Full:
subscribers.remove(q)
@app.get("/events")
def events():
q: queue.Queue = queue.Queue(maxsize=10)
subscribers.append(q)
@stream_with_context
def gen():
try:
while True:
try:
name, payload = q.get(timeout=15)
yield f"event: {name}ndata: {payload}nn"
except queue.Empty:
yield ": heartbeatnn"
finally:
subscribers.remove(q)
return Response(gen(), mimetype="text/event-stream")
Now call broadcast("task-update", render_partial()) at the end of every mutating endpoint. Every browser tab that has the page open receives the new HTML and swaps it into the list – multi-user collaboration with about 30 lines of Python. The : heartbeat comment line every 15 seconds keeps proxies like Cloudflare from severing idle connections.
Common Pitfall: SSE Behind Gunicorn’s Default Sync Worker
Gunicorn’s default sync worker class blocks on long-lived connections, so an SSE stream consumes one worker indefinitely. Run gunicorn with --worker-class gevent --workers 4 --worker-connections 1000 in production. With four gevent workers you can hold roughly 4,000 concurrent SSE clients on a single VPS, which is more than enough for almost any internal tool.
Step 11: Add Optimistic UI With hx-swap-oob and Loading Indicators
HTMX cannot do true optimistic updates the way a Redux store can – the source of truth is the server – but it can do something close: instantly add a placeholder row, swap it for the real row when the response arrives, and roll back on error. The trick is hx-swap-oob (“out-of-band”), which lets a single response update multiple regions of the page at once.
<!-- partials/task_row.html with OOB counter -->
<li id="task-{{ t.id }}" class="...">...</li>
<span id="task-count" hx-swap-oob="true"
class="ml-2 text-sm text-slate-500">
{{ count }} tasks
</span>
Any element with hx-swap-oob="true" inside an HTMX response is detached from the main payload and used to replace the element with the same id elsewhere on the page. Use it to refresh sidebars, badges, breadcrumbs, or progress bars without a separate request.
Step 12: Toast Notifications via HX-Trigger Headers
Custom DOM events are the underrated part of HTMX. Set the HX-Trigger response header to a JSON object and HTMX dispatches a custom event with the payload as detail. Wire one listener and you have a global toast system.

# Set the header on a successful create
return response, 201, {
"HX-Trigger": json.dumps({
"showToast": {"level": "success", "msg": "Task added"}
})
}
<!-- Add to base.html, just before closing body -->
<script>
document.body.addEventListener("showToast", (e) => {
const {level, msg} = e.detail;
const div = document.createElement("div");
div.className = `mb-2 px-4 py-2 rounded text-white shadow ${
level === "success" ? "bg-green-600" : "bg-red-600"
}`;
div.textContent = msg;
document.getElementById("toast").appendChild(div);
setTimeout(() => div.remove(), 3000);
});
</script>
That listener works for every endpoint in the codebase – create, update, delete, errors. Adding a new toast variant means adding a class branch, not a new event subscriber. The Hypermedia Systems book by Carson Gross dedicates an entire chapter to this pattern.
Step 13: Deploy to Production With Gunicorn, Caddy, and CSRF Protection
The deployment story is intentionally boring: gunicorn behind Caddy with automatic HTTPS. The full Caddyfile for this app is six lines, and the gunicorn invocation is one. No webpack, no Rollup, no Turbopack, no service worker.
# Procfile or systemd ExecStart
gunicorn app:app
--worker-class gevent
--workers 4
--worker-connections 1000
--bind 127.0.0.1:8000
--access-logfile -
# Caddyfile
tasks.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8000
header /events Cache-Control "no-cache"
header /events X-Accel-Buffering "no"
}
Caddy ships free TLS via Let’s Encrypt and outperforms Nginx by a measurable margin in our recent Caddy vs Nginx 2026 benchmark. The two header rules disable buffering on /events so SSE messages stream immediately instead of being held by the proxy.
Performance: HTMX vs React in a 100-User Load Test
To put numbers behind the architecture choice, we benchmarked the same task list rendered three ways – an HTMX + Flask version, a Next.js 14 App Router version, and a classic Create React App (CRA) version – on a $5/month Hetzner CX22 (2 vCPU, 4 GB RAM). Each test ran k6 with 100 concurrent virtual users for 60 seconds.
| Metric | HTMX + Flask | Next.js 14 | CRA + REST |
|---|---|---|---|
| Initial JS payload | 5 KB | 97 KB | 148 KB |
| Time to Interactive | 0.4 s | 1.1 s | 1.9 s |
| Median request latency | 18 ms | 34 ms | 62 ms |
| p99 latency | 71 ms | 142 ms | 238 ms |
| Requests/sec sustained | 4,210 | 1,860 | 1,440 |
| Lines of frontend code | 180 | 520 | 740 |
| Build step | None | Required | Required |
The HTMX version is not faster because HTMX is magical – it is faster because it does less work. There is no hydration, no reconciliation, no client-side router. Every request returns a tiny HTML fragment that the browser drops directly into the DOM.
Two caveats are worth flagging before you cite these numbers internally. First, the request-latency advantage shrinks once round-trip times to the database dominate the response time – the HTMX win is largest on small payloads where serialization, hydration, and client routing cost real time. Second, the lines-of-code metric ignores the React ecosystem’s mature component libraries; you trade boilerplate for direct control. The point of the table is not “HTMX is always better” but “for this exact CRUD workload, HTMX wins on every dimension that matters to the user.”
Security Considerations: CSRF, XSS, and Trusted HTML
HTMX trades JSON for HTML, which changes the threat model in two specific ways every team should understand before shipping. The good news: Flask, Jinja2, and the patterns in this tutorial already handle most of the risks safely by default.
Cross-site scripting (XSS) is the headline concern. Because the server’s response is injected directly into the DOM, any unescaped user input becomes a script tag. Jinja2 auto-escapes {{ variable }} output by default; never use {{ variable | safe }} on anything a user typed. If you must render user-supplied HTML, run it through a sanitizer like bleach with an explicit allow-list of tags – do not invent your own filter.
Cross-site request forgery (CSRF) is solved in this tutorial by Flask-WTF’s CSRFProtect plus the hx-headers attribute we placed on the body in Step 4. Every state-changing HTMX request now carries an X-CSRFToken header that Flask-WTF validates server-side. The token rotates per session, so an attacker cannot replay an old one. Never accept POST/PUT/DELETE without this guard, even on internal tools – one curious DNS-rebinding attack and your local network becomes the attacker’s playground.
Content Security Policy (CSP) is the third lever. Add a strict CSP header that bans inline scripts and only allows https://unpkg.com for HTMX itself. The hx-on: attribute uses inline event handlers, which a strict CSP would block; if you go that route, use htmx.config.allowEval = false and do all your scripting in external files or hyperscript instead. The HTMX 4.0 release – tagged “Latest” on GitHub as v4.0.0 on August 28, 2026 – tightens these defaults further by removing several APIs that needed unsafe-eval, makes attribute inheritance explicit via a new :inherited modifier – per InfoQ’s September 2026 writeup – so CSP-sensitive teams can audit exactly which elements pass attributes down, and ships an npx [email protected] upgrade-check command that scans your templates across 9 file extensions to flag anything still relying on them.
Five Common HTMX Pitfalls and How to Avoid Them
Almost every HTMX bug we have seen in production code review comes from one of five recurring traps. Reading them once will save you a debugging afternoon.

- Returning JSON instead of HTML. HTMX swaps response bodies as text into the DOM. If you return
{"id":1}, that JSON appears literally on the page. Always render a partial template – or setHX-Reswap: noneif you genuinely have nothing to swap. - Forgetting
hx-target. The default target is the element that triggered the request. For a button inside a list item, that means the button itself gets replaced. Always sethx-targetexplicitly when the visual outcome is not “replace this exact element”. - CSRF tokens missing on POST/PUT/DELETE. Browsers do not include any token automatically. Use
hx-headerson a parent element (we put it on<body>in Step 4) or rely on thehtmx:configRequestevent to inject one globally. - Race conditions on rapid clicks. If a user clicks a button three times in a second, three requests fly. Add
hx-disabled-elt="this"orhx-sync="this:replace"to serialize them. - Not handling 4xx/5xx responses. By default, HTMX does not swap responses with non-2xx status codes. Configure
htmx.config.responseHandlingor useHX-Reswapresponse headers to render error fragments.
Eight Troubleshooting Items From Real HTMX Builds
- “My swap does nothing.” Open DevTools Network tab. If the response is empty or 204, HTMX swaps nothing. Either return a fragment or set
hx-swap="none"intentionally. - “Inline event handlers stop working after swap.” Inline
onclick="..."handlers run on initial page load only. Use HTMX events (hx-on:click) or hyperscript so they bind on every swapped fragment. - “Form data is empty in Flask.” HTMX submits forms as
application/x-www-form-urlencoded. Userequest.form.get(), notrequest.json. - “SSE disconnects after 60 seconds on Cloudflare.” Cloudflare buffers and times out streams. Set
X-Accel-Buffering: noand send a heartbeat comment every 15 seconds (we do both in Step 10). - “Browser back button shows an empty page.” HTMX’s history cache does not store partial responses by default. Add
hx-push-url="true"to navigation triggers and return full pages on direct hits. - “Confirm dialog appears even after I cancel.” Listen for
htmx:confirmand callevent.preventDefault()when your modal cancels – otherwise HTMX falls back towindow.confirm. - “hx-boost makes my whole site reload.”
hx-boostintercepts links and forms but does not change the response shape. Your boosted endpoints must still return full HTML pages, not fragments. - “403 errors on every POST.” CSRF rejection. Confirm the token is in
hx-headersand matches the session. The Flask-WTF default cookie name iscsrf_token.
Advanced Tips for Scaling HTMX Beyond the Demo
Once the basics click, three patterns will let you build complex UIs without reaching for a SPA framework. None of them require additional dependencies.
- Pair with hyperscript or Alpine.js for inline state. HTMX does not own client state. For a dropdown, modal, or accordion, hyperscript’s
_="on click toggle .open on #menu"is far smaller than React’s equivalent. The official HTMX docs link to both libraries. - Use
hx-includeto compose forms. A delete button inside a row can include the search input’s value withhx-include="[name=q]"so the server returns the next paginated item that matches the current query. - Lean on HTTP semantics. Cache GETs aggressively with
Cache-Control: max-age=60. UseETagheaders; HTMX honors 304 Not Modified responses for free. - Migrate progressively. Add
hx-boost="true"on the body of a server-rendered Rails or Django app and every link becomes an AJAX page swap with no other changes – you have shipped the SPA experience without the SPA. - Plan for HTMX 4.0 today. HTMX 4.0.0 launched on August 28, 2026, replacing XHR with the Fetch API, making attribute inheritance explicit by default, and renaming a handful of attributes. Start using
hx-on::double-colon syntax instead ofhx-on:; that is the future-compatible spelling per the official migration guide.
Output Examples From the Running App
To make the request/response cycle concrete, here are three actual exchanges captured from the running app with curl.
# Search request
$ curl -s "http://localhost:8000/tasks?q=docs"
-H "HX-Request: true"
<ul id="task-items" class="space-y-2">
<li id="task-12" class="...">
<span>Write HTMX docs</span>
...
</li>
</ul>
# Create response (HTTP 201 with HX-Trigger header)
$ curl -s -i -X POST http://localhost:8000/tasks
-d "title=Ship&csrf_token=..."
HTTP/1.1 201 CREATED
HX-Trigger: {"showToast":{"level":"success","msg":"Task added"}}
<li id="task-42" class="...">
<span>Ship</span>
...
</li>
# Delete response (empty body, HX-Trigger only)
$ curl -s -i -X DELETE http://localhost:8000/tasks/42
HTTP/1.1 200 OK
HX-Trigger: task-deleted
Content-Length: 0
HTMX vs The Field: When to Reach for It
| Use Case | HTMX | React/Next.js | Vanilla JS |
|---|---|---|---|
| Server-rendered admin panel | Best fit | Overkill | OK for tiny sites |
| Internal CRUD tool | Best fit | Common but heavy | Painful at scale |
| Marketing site with islands | Good with Alpine | Good (Astro) | OK |
| Highly interactive editor (Figma-like) | Wrong tool | Best fit | Possible |
| Real-time multi-user app | Good with SSE | Good with WebSockets | Possible |
| Mobile-first PWA | Workable | Best fit | Workable |
| Static blog | Unnecessary | Overkill | Best fit |
The honest summary: HTMX is the right answer for most CRUD apps and admin tools that today are over-engineered as React SPAs. It is the wrong answer for canvas editors, drag-and-drop builders, and offline-first PWAs where the client owns substantial state.
Frequently Asked Questions
Is HTMX production-ready in 2026?
Yes. The 2.x line is declared feature-complete by Carson Gross, the library has 47,920 GitHub stars and 152,014 weekly NPM downloads, and Drupal 12.x ships HTMX in core. W3Techs measured HTMX running on 0.1% of all sites it crawls, with 48.2% of those sites already on the 2.x branch, as of August 8, 2026. The htmx GitHub releases page tagged v4.0.0 as “Latest” on August 28, 2026 – the project’s 34th recorded release – while the 2.0.10 patch from April 21, 2026 keeps fixing TypeScript definitions and escaping bugs for teams staying on 2.x long-term maintenance.
Can I use HTMX with Django, FastAPI, or Go instead of Flask?
Absolutely. HTMX is server-agnostic; it only needs a backend that can return HTML fragments. Django with its template language is an excellent fit – Sparrow.so’s December 2025 Django+HTMX guide gets a project running by installing just one package, django-htmx, for the request/response middleware, and django-htmx 1.28.0 added HTMX 4 beta support on July 12, 2026 – FastAPI works with Jinja2 templates, and Go’s html/template package was almost designed for this style.
How big is the HTMX bundle?
The HTMX 2.0.10 minified file, measured off jsDelivr in July 2026 by Dplooy’s full-stack htmx tutorial, comes in at approximately 51 KB on disk and roughly 16 KB after gzip. For comparison, React plus react-dom together weigh in at about 40–130 KB depending on your bundler configuration, before you add any application code.
Does HTMX support TypeScript?
HTMX itself is a runtime library, so there is no TypeScript API to type. You can absolutely use TypeScript on the server (FastAPI, NestJS, ts-node) and HTMX will not care. The few JavaScript bits you write – event listeners and hyperscript – can live in a typed .ts file if your backend serves it.
Is HTMX SEO-friendly?
It is the most SEO-friendly architecture short of a static site. Every URL returns a fully-rendered HTML page on the first request – no JavaScript hydration, no client routing, no document.title updates needed. Search engines see exactly what users see.
What is the difference between HTMX 2.x and HTMX 4.0?
HTMX 4.0 – which skipped version 3 entirely, jumping straight from the 2.0 line introduced in 2024 to 4.0, per InfoQ’s September 2026 analysis – tagged v4.0.0 and marked “Latest” on GitHub on August 28, 2026, the project’s 34th recorded release, after passing through betas like the 4.0.0-beta6 build Dplooy’s tutorial pinned on July 28, 2026 – swaps the underlying transport from XMLHttpRequest to the Fetch API, which enables streaming response bodies via ReadableStream. It also adds an hx-status swap selector for HTTP-specific behavior, renames a few attributes (hx-disable becomes hx-ignore), introduces a 60-second default timeout, ships an htmx-2-compat extension to keep older code running unchanged, and bundles an npx [email protected] upgrade-check tool that scans 9 file extensions to catch anything that needs updating.
Can HTMX replace React entirely?
For server-rendered apps that mostly do CRUD, yes. For client-heavy tools like Figma, Linear, or Google Sheets, no – the client genuinely owns state there and a reactive framework is the right model. The 2025 State of JS results show HTMX rising fastest in the “interest” and “admiration” categories while React’s satisfaction continues to slide.
Where can I learn more after this htmx tutorial?
The two essential resources are the official HTMX documentation and the free book Hypermedia Systems by Carson Gross. The HTMX GitHub repository hosts an active discussions forum, and the State of JS survey tracks adoption trends each year.
Related Coverage
- Flask REST API tutorial – the Python backend used in this article, with deeper coverage of Flask 3.0 patterns.
- FastAPI REST API tutorial – an async alternative to Flask that pairs equally well with HTMX.
- Gin Go tutorial – a Go backend choice for HTMX apps that need raw throughput.
- Tailwind CSS dashboard tutorial – pair Tailwind utility classes with HTMX swaps for a no-build UI.
- Svelte vs React 2026 – another low-bundle alternative to React for cases where HTMX is too server-heavy.
- Next.js full-stack tutorial – the React-shaped comparison point for the benchmark table above.
- Caddy vs Nginx 2026 – the reverse proxy used in Step 13 deployment.
The Bottom Line
HTMX 2.0.9 is the easiest way in 2026 to build a fast, server-rendered web app without sacrificing modern UX. In thirteen steps this tutorial built a complete task manager with live search, click-to-edit, real-time SSE updates, optimistic UI, toast notifications, CSRF protection, and a production deployment – in roughly 250 lines of Python and 180 lines of templates, with a 5 KB JavaScript footprint.
The architecture you just learned scales further than most teams expect. Drupal ships it. The State of JS survey ranks it the most admired library two years running. And because the entire mental model is “the server returns HTML and the browser puts it on the page”, every developer on your team already understands it.
The next steps depend on your project. If you are starting greenfield, take this codebase as a template – clone the structure, swap the data model, and you have a head start of perhaps two days. If you are migrating an existing Flask, Django, or Rails app, add the HTMX script tag and one hx-boost="true" attribute on the body and your existing pages instantly load without full reloads; you can then introduce fragment endpoints one feature at a time without breaking anything. If you are evaluating HTMX against a React rewrite, build the same screen twice and time the result – the table earlier in this article will likely match what you measure on your own hardware.
Whichever path you take, pin the HTMX version, keep partials small, and let the server own the state. That is the entire philosophy – everything else is implementation detail.





