Overview
I built a small internal tool last month and started reaching for React out of habit. Then I looked at what the tool actually did: display a list, let users edit rows inline, and filter with a search box. That's HTML forms and a bit of partial rendering. HTMX does that in about 9 kB, and the server does the work it was already doing[reference:14].
This is the pattern, and where it stops being the right tool.
The core idea in one example
A button that fetches content and swaps it into the page:
<button hx-get="/tasks/" hx-target="#task-list" hx-swap="innerHTML">
Refresh
</button>
<div id="task-list">
{% include "tasks/_list.html" %}
</div>
Click the button. HTMX sends a GET to /tasks/. Your Django view returns an HTML fragment. HTMX drops it into #task-list. No JavaScript written, no JSON serialization, no client-side state.
The Django side:
# views.py
def task_list(request):
tasks = Task.objects.filter(completed=False)
return render(request, "tasks/_list.html", {"tasks": tasks})
That's the whole integration. The view returns a partial template, and HTMX handles the swap.
The attributes you'll actually use
| Attribute | What it does |
|---|---|
hx-get, hx-post, hx-put, hx-delete | Send an HTTP request |
hx-target | Where to put the response |
hx-swap | How to insert: innerHTML, outerHTML, beforeend, afterbegin |
hx-trigger | What event fires the request: click, keyup changed delay:300ms, load |
hx-confirm | Show a browser confirm dialog before sending |
hx-indicator | Element to show while the request is in flight |
Inline editing
Click a value, get an input. Blur or Enter saves it. This is the pattern that sells people on HTMX.
<!-- Display state -->
<td hx-get="/tasks/{{ task.id }}/edit/"
hx-trigger="click"
hx-swap="outerHTML">
{{ task.title }}
</td>
<!-- Edit state (returned by the view) -->
<td>
<form hx-put="/tasks/{{ task.id }}/"
hx-target="this"
hx-swap="outerHTML">
{% csrf_token %}
<input name="title" value="{{ task.title }}" autofocus>
<button type="submit">Save</button>
</form>
</td>
def task_edit(request, pk):
task = get_object_or_404(Task, pk=pk)
return render(request, "tasks/_edit_form.html", {"task": task})
def task_update(request, pk):
task = get_object_or_404(Task, pk=pk)
if request.method == "PUT":
task.title = request.POST.get("title", task.title)
task.save()
return render(request, "tasks/_display.html", {"task": task})
Notice the symmetry: GET returns the edit form, PUT saves and returns the display state. The same URL and the same target, with the server deciding what the HTML should look like at each step.
Search with debounce
<input type="search"
name="q"
hx-get="/tasks/search/"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-indicator="#spinner">
<span id="spinner" class="htmx-indicator">Searching...</span>
<div id="results"></div>
changed means only fire if the value actually changed (not on every keyup, including arrow keys). delay:300ms debounces. The htmx-indicator class is provided by HTMX and toggles visibility while a request is active.
def task_search(request):
q = request.GET.get("q", "")
tasks = Task.objects.filter(title__icontains=q)[:20] if q else []
return render(request, "tasks/_results.html", {"tasks": tasks})
Handling the Django details
CSRF tokens
HTMX sends form data as a normal form submission, so the CSRF token needs to be present. If you're using hx-post on something that isn't a form, include it explicitly:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
Setting it on <body> makes it apply to every HTMX request on the page, which is the least annoying approach.
Telling the difference between full-page and partial requests
Sometimes a view needs to return a full page for direct navigation and a fragment for HTMX. Check the header:
def task_list(request):
tasks = Task.objects.all()
template = (
"tasks/_list.html"
if request.headers.get("HX-Request")
else "tasks/list.html"
)
return render(request, template, {"tasks": tasks})
HX-Request is true on every request HTMX makes.
Redirects
HTMX follows HTTP redirects transparently, which is usually wrong. A redirect after a form POST should navigate the browser, not swap content into a div. Use the special response header:
def create_task(request):
if request.method == "POST":
Task.objects.create(title=request.POST["title"])
response = HttpResponse(status=204)
response["HX-Redirect"] = "/tasks/"
return response
HX-Redirect makes the browser navigate. HX-Location does a client-side swap without a full page load. Both are more predictable than letting a 302 get followed and swapped in.
Progressive enhancement
One of the quieter benefits: HTMX works with standard HTML rather than replacing it. If JavaScript fails to load, the form still submits and the link still navigates.
<form method="post" action="/tasks/"
hx-post="/tasks/"
hx-target="#task-list"
hx-swap="afterbegin">
{% csrf_token %}
<input name="title">
<button>Add</button>
</form>
With HTMX loaded, the form submits via AJAX and the new task appears without a page reload. Without it, the form does a normal POST. Same markup, two behaviors.
When this breaks down
HTMX is wrong for anything where the client needs to hold state that the server doesn't know about. Rich text editing with collaborative cursors, drag-and-drop with optimistic updates across many items, anything resembling a spreadsheet — those want a client-side state model.
| Use HTMX when | Use a JS framework when |
|---|---|
| The server already knows the data | Client-side state is the source of truth |
| Interactions are discrete: click, submit, filter | Interactions are continuous: drag, resize, type-as-you-go with local validation |
| You want to ship features without a build step | You need a component ecosystem |
| The page is fundamentally a document | The page is fundamentally an application |
For the internal tool I built — a list, inline editing, and search — it was the right call. For the customer-facing dashboard with live charts and optimistic updates, it wasn't, and I used React.
Installing it
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
Or via pip if you want the Django integration helpers:
pip install django-htmx
Then add django_htmx to INSTALLED_APPS and django_htmx.middleware.HtmxMiddleware to middleware. It gives you request.htmx with helpers like request.htmx.trigger and request.htmx.target, which saves checking raw headers.
The whole thing took an afternoon to learn. The first feature took an hour. The second took twenty minutes. That's the shape of the curve.
