JavaScript Advanced

HTMX with Django: Server-Rendered Interactivity Without React or Vue

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.

DjangoZen Team Apr 25, 2026 20 min read 14 views

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.

The HTMX philosophy

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.

Install

<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.

CSRF — wire it once

<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.

The four attributes you'll use 90% of the time

  • 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).

Example 1 — inline form, no full reload

<!-- 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.

Example 2 — live validation

<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.

Example 3 — infinite scroll

{% 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.

Example 4 — modal that loads from server

<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.

Out-of-band swaps for multi-region updates

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.

Real-time updates with SSE

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.

When NOT to use HTMX

  • Apps with heavy client-side state (real-time collaborative editor, complex drag-and-drop, in-browser data manipulation tools).
  • Truly offline-first PWAs.
  • Designs that need optimistic UI updates everywhere — possible with HTMX (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.

Production checklist

  • CSRF wired globally via htmx:configRequest.
  • Use HX-Request header in views to detect HTMX requests when you need to return a fragment vs. full page on the same URL.
  • Set sensible hx-swap defaults — innerHTML is the default but outerHTML for full-component re-renders is often what you want.
  • Add hx-indicator for loading states; users notice 200ms latencies.
  • Test with the Network panel — every HTMX request should be small (a few KB of HTML), not "render the whole page server-side."

Summary

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.