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

AttributeWhat it does
hx-get, hx-post, hx-put, hx-deleteSend an HTTP request
hx-targetWhere to put the response
hx-swapHow to insert: innerHTML, outerHTML, beforeend, afterbegin
hx-triggerWhat event fires the request: click, keyup changed delay:300ms, load
hx-confirmShow a browser confirm dialog before sending
hx-indicatorElement 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 whenUse a JS framework when
The server already knows the dataClient-side state is the source of truth
Interactions are discrete: click, submit, filterInteractions are continuous: drag, resize, type-as-you-go with local validation
You want to ship features without a build stepYou need a component ecosystem
The page is fundamentally a documentThe 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.