HTMX with Django: SPA Feel without JS Fatigue

python backend django htmx frontend

The pendulum is swinging back. After years of React/Vue/Angular, developers are rediscovering server-rendered HTML. HTMX makes it interactive without the JavaScript complexity.

What is HTMX?

HTMX extends HTML with attributes that enable:

No JavaScript required (beyond the htmx.js library).

<!-- Click button, fetch HTML, insert into #result -->
<button hx-get="/api/data" hx-target="#result">
  Load Data
</button>
<div id="result"></div>

Setup with Django

Installation

<!-- In base.html -->
<script src="https://unpkg.com/htmx.org@1.9.0"></script>

Or via npm/CDN/vendor.

Basic Pattern

# views.py
from django.shortcuts import render

def index(request):
    return render(request, 'index.html')

def load_items(request):
    items = Item.objects.all()[:10]
    return render(request, 'partials/items.html', {'items': items})
<!-- templates/index.html -->
<button hx-get="{% url 'load_items' %}" hx-target="#items-container">
  Load Items
</button>
<div id="items-container"></div>

<!-- templates/partials/items.html -->
{% for item in items %}
<div class="item">{{ item.name }}</div>
{% endfor %}

Common Patterns

Infinite Scroll

<!-- templates/partials/items.html -->
{% for item in items %}
<div class="item">{{ item.name }}</div>
{% endfor %}

{% if has_more %}
<div hx-get="{% url 'load_items' %}?page={{ next_page }}"
     hx-trigger="revealed"
     hx-swap="afterend">
  <!-- Loading indicator -->
</div>
{% endif %}
# views.py
def load_items(request):
    page = int(request.GET.get('page', 1))
    items = Item.objects.all()[(page-1)*10:page*10]
    has_more = Item.objects.count() > page * 10
    return render(request, 'partials/items.html', {
        'items': items,
        'has_more': has_more,
        'next_page': page + 1
    })

Inline Editing

<!-- Display mode -->
<div id="item-{{ item.id }}">
  <span>{{ item.name }}</span>
  <button hx-get="{% url 'item_edit' item.id %}" 
          hx-target="#item-{{ item.id }}"
          hx-swap="outerHTML">
    Edit
  </button>
</div>

<!-- Edit mode (returned from server) -->
<form hx-put="{% url 'item_update' item.id %}"
      hx-target="#item-{{ item.id }}"
      hx-swap="outerHTML">
  <input name="name" value="{{ item.name }}">
  <button type="submit">Save</button>
  <button hx-get="{% url 'item_view' item.id %}"
          hx-target="#item-{{ item.id }}"
          hx-swap="outerHTML">Cancel</button>
</form>

Search as You Type

<input type="search" 
       name="q"
       hx-get="{% url 'search' %}"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#search-results">

<div id="search-results"></div>

Form Submission

<form hx-post="{% url 'create_item' %}"
      hx-target="#items-list"
      hx-swap="beforeend">
  {% csrf_token %}
  <input name="name" required>
  <button type="submit">Add</button>
</form>

<div id="items-list">
  {% for item in items %}
  <div>{{ item.name }}</div>
  {% endfor %}
</div>
# views.py
def create_item(request):
    if request.method == 'POST':
        item = Item.objects.create(name=request.POST['name'])
        return render(request, 'partials/item_row.html', {'item': item})

HTMX Attributes

AttributePurpose
hx-get, hx-post, hx-put, hx-deleteHTTP methods
hx-targetWhere to put response
hx-swapHow to insert (innerHTML, outerHTML, beforeend, etc.)
hx-triggerWhat triggers request
hx-indicatorLoading indicator
hx-confirmConfirmation dialog

Django Specific Tips

CSRF Handling

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

Or per-form with {% csrf_token %}.

Detecting HTMX Requests

def my_view(request):
    if request.headers.get('HX-Request'):
        # Partial response
        return render(request, 'partials/content.html')
    else:
        # Full page
        return render(request, 'full_page.html')

django-htmx Package

pip install django-htmx
# views.py
def my_view(request):
    if request.htmx:
        if request.htmx.boosted:
            # Boosted navigation
            pass
        return render(request, 'partials/content.html')

Adds request.htmx with useful properties.

Messages Integration

<!-- In base template -->
<div id="messages" hx-swap-oob="true">
  {% for message in messages %}
  <div class="alert alert-{{ message.tags }}">{{ message }}</div>
  {% endfor %}
</div>

Out-of-band swaps update messages on any HTMX response.

vs JavaScript Frameworks

AspectHTMXReact/Vue
Learning curveLowHigher
Bundle size14KB100KB+
Server couplingTightLoose
Real-timeWebSocket supportFull control
Complex stateLimitedExcellent
SEOGreat (server-rendered)Needs SSR

When to Use HTMX

✅ Traditional web apps with interactivity ✅ Progressive enhancement ✅ Teams with Django/Rails expertise ✅ Content-heavy sites ✅ Admin panels and dashboards

❌ Highly interactive apps (Figma, Google Docs) ❌ Offline-first applications ❌ Complex client-side state management

Final Thoughts

HTMX isn’t replacing React. It’s an alternative for different use cases.

If your app is basically CRUD with some interactivity, HTMX + Django is simpler than a full SPA. You keep your Django templates, your Django views, your Django ecosystem.

Try it on your next internal tool. You might be surprised how far it gets you.


Sometimes the old ways are the better ways.

All posts