HTMX brings SPA-feel interactions back to server-rendered Django: inline forms, live validation, infinite scroll, modals, and partial updates — all from regular Django views returning HTML fragments. The full pattern with CSRF and SSE.
If your Django frontend is "render a full template, post a form, redirect" and you wish it felt more like an SPA without committing to React — HTMX is the answer. It's 14kB of JavaScript that lets you do AJAX, partial updates, infinite scroll, modals, and real-time updates with HTML attributes. Your views stay in Django, your templates stay in Django, your auth stays in Django. You just send HTML fragments instead of JSON.
Instead of building a JavaScript app that fetches JSON and renders it client-side, you write Django views that return small HTML fragments. HTMX inserts those fragments into the page in response to user interactions. The server stays the source of truth — no client-side state to keep in sync, no API to design twice.
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<meta name="csrf-token" content="{% raw %}{{ csrf_token }}{% endraw %}">
That's it. No build step, no bundler, no node_modules.
<script>
document.body.addEventListener("htmx:configRequest", (e) => {
e.detail.headers["X-CSRFToken"] =
document.querySelector('meta[name="csrf-token"]').content;
});
</script>
Now every HTMX request sends the CSRF token. Django's CSRF middleware accepts it.
hx-get="/url/" — fire a GET; replace target with the response.hx-post="/url/" — fire a POST.hx-target="#some-id" — element to swap.hx-trigger="click" — what triggers the request (default: form submit / button click).<!-- template -->
<div id="comment-list">
{% raw %}{% include "partials/_comment_list.html" %}{% endraw %}
</div>
<form hx-post="{% raw %}{% url 'add_comment' post.id %}{% endraw %}"
hx-target="#comment-list"
hx-swap="outerHTML">
{% raw %}{% csrf_token %}{% endraw %}
<textarea name="body" required></textarea>
<button>Post</button>
</form>
# views.py
def add_comment(request, post_id):
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
form.instance.post_id = post_id
form.instance.user = request.user
form.save()
# Re-render JUST the comment list partial
return render(request, "partials/_comment_list.html",
{"comments": Comment.objects.filter(post_id=post_id)})
The browser sends the form via AJAX, the server returns a chunk of HTML, HTMX swaps it in. Page never reloads. View is still a normal Django view.
<input name="username"
hx-get="{% raw %}{% url 'check_username' %}{% endraw %}"
hx-trigger="keyup changed delay:300ms"
hx-target="#username-feedback">
<span id="username-feedback"></span>
def check_username(request):
name = request.GET.get("username", "")
if not name: return HttpResponse("")
if User.objects.filter(username=name).exists():
return HttpResponse('<span class="text-danger">Taken</span>')
return HttpResponse('<span class="text-success">Available</span>')
The changed modifier fires only when the value actually changes. delay:300ms debounces. No JS framework, no fetch boilerplate.
{% raw %}{% for product in products %}
<article>{{ product.title }}</article>
{% endfor %}
{% if products.has_next %}
<div hx-get="?page={{ products.next_page_number }}"
hx-trigger="revealed"
hx-swap="outerHTML">
Loading…
</div>
{% endif %}{% endraw %}
The revealed trigger fires when the element scrolls into view. The server renders the next page including its OWN sentinel. Replace the sentinel with itself + new content. That's it. No intersection observer code on the client.
<button hx-get="{% raw %}{% url 'edit_product' p.id %}{% endraw %}"
hx-target="#modal-content"
hx-trigger="click"
onclick="modal.showModal()">Edit</button>
<dialog id="modal">
<div id="modal-content"></div>
</dialog>
The view renders the form inside the modal. After successful POST, return an empty 204 with HX-Trigger: closeModal header — and a tiny JS listener closes the dialog. Forms in modals, no SPA.
Sometimes one action should update two parts of the page (e.g., add a comment AND update the comment count in the header):
{% raw %}
…new list…
42{% endraw %}
The main target gets the comment list; HTMX also locates #comment-count and swaps it. One round trip, two updated regions.
For dashboards, notifications, live feeds — Server-Sent Events are simpler than WebSockets and work natively with HTMX:
<div hx-ext="sse"
sse-connect="/notifications/stream/"
sse-swap="message"
hx-target="#notifications"
hx-swap="afterbegin"></div>
Your Django view is a generator yielding data: <li>…</li>\n\n. Use Django Channels or just plain Django + async views (3.1+) with StreamingHttpResponse.
hx-disable, hx-indicator) but JS frameworks are smoother.For 80% of Django CRUD apps, dashboards, and content sites: HTMX wins on velocity and maintainability. One language, one set of templates, one auth story.
htmx:configRequest.HX-Request header in views to detect HTMX requests when you need to return a fragment vs. full page on the same URL.hx-swap defaults — innerHTML is the default but outerHTML for full-component re-renders is often what you want.hx-indicator for loading states; users notice 200ms latencies.HTMX puts the server back in charge of HTML. Your Django views return fragments. Templates stay templates. Forms stay forms. Auth stays Django. The result is dramatically less JavaScript, no API to maintain alongside your views, and pages that feel modern. For Django shops, it's the highest-leverage frontend choice of the decade.