HTMX + Django: Full Stack Python in 2022

django python htmx

HTMX lets you build modern, interactive UIs without writing JavaScript. Combined with Django, you get a full-stack Python experience. Here’s how.

What is HTMX

HTMX extends HTML with attributes that make AJAX requests and update the DOM:

<button hx-post="/clicked" hx-swap="outerHTML">
    Click Me
</button>

When clicked:

  1. Makes POST request to /clicked
  2. Replaces the button with the response HTML

No JavaScript. Just HTML attributes.

Setup

pip install django-htmx
# settings.py
INSTALLED_APPS = [
    ...
    'django_htmx',
]

MIDDLEWARE = [
    ...
    'django_htmx.middleware.HtmxMiddleware',
]
<!-- base.html -->
<head>
    <script src="https://unpkg.com/htmx.org@1.8.4"></script>
</head>

Core Patterns

Click to Load

<!-- templates/todos/list.html -->
<div id="todo-list">
    {% for todo in todos %}
        <div class="todo">{{ todo.title }}</div>
    {% endfor %}
    
    {% if has_more %}
    <button hx-get="{% url 'todos:list' %}?page={{ next_page }}"
            hx-target="#todo-list"
            hx-swap="beforeend">
        Load More
    </button>
    {% endif %}
</div>
# views.py
def todo_list(request):
    page = int(request.GET.get('page', 1))
    todos = Todo.objects.all()[(page-1)*10:page*10]
    
    template = 'todos/partials/items.html' if request.htmx else 'todos/list.html'
    return render(request, template, {
        'todos': todos,
        'has_more': Todo.objects.count() > page * 10,
        'next_page': page + 1
    })

Inline Editing

<!-- templates/todos/todo_row.html -->
<div id="todo-{{ todo.id }}" class="todo-row">
    <span>{{ todo.title }}</span>
    <button hx-get="{% url 'todos:edit' todo.id %}"
            hx-target="#todo-{{ todo.id }}"
            hx-swap="outerHTML">
        Edit
    </button>
</div>
<!-- templates/todos/todo_edit.html -->
<form id="todo-{{ todo.id }}"
      hx-put="{% url 'todos:update' todo.id %}"
      hx-target="this"
      hx-swap="outerHTML">
    <input type="text" name="title" value="{{ todo.title }}">
    <button type="submit">Save</button>
    <button hx-get="{% url 'todos:detail' todo.id %}"
            hx-target="#todo-{{ todo.id }}"
            hx-swap="outerHTML">
        Cancel
    </button>
</form>
# views.py
def todo_update(request, pk):
    todo = get_object_or_404(Todo, pk=pk)
    todo.title = request.POST.get('title')
    todo.save()
    return render(request, 'todos/todo_row.html', {'todo': todo})

Delete with Confirmation

<button hx-delete="{% url 'todos:delete' todo.id %}"
        hx-confirm="Are you sure?"
        hx-target="#todo-{{ todo.id }}"
        hx-swap="outerHTML swap:1s">
    Delete
</button>

Search with Debounce

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

<div id="results">
    {% include 'search/results.html' %}
</div>
# views.py
def search(request):
    q = request.GET.get('q', '')
    results = Item.objects.filter(name__icontains=q)[:20] if q else []
    return render(request, 'search/results.html', {'results': results})

Form Validation

<!-- Real-time username validation -->
<input type="text" name="username"
       hx-post="{% url 'validate_username' %}"
       hx-trigger="blur changed"
       hx-target="#username-error">
<span id="username-error"></span>
# views.py
def validate_username(request):
    username = request.POST.get('username', '')
    
    if not username:
        return HttpResponse('<span class="error">Username required</span>')
    
    if User.objects.filter(username=username).exists():
        return HttpResponse('<span class="error">Username taken</span>')
    
    return HttpResponse('<span class="success">Username available</span>')

Advanced Patterns

Polling

<div hx-get="{% url 'notifications' %}"
     hx-trigger="every 30s"
     hx-target="#notification-count">
    <span id="notification-count">{{ count }}</span>
</div>

Progress Indicator

<button hx-post="{% url 'long_task' %}"
        hx-indicator="#spinner">
    Start Task
</button>

<div id="spinner" class="htmx-indicator">
    <img src="/static/spinner.gif">
</div>
.htmx-indicator {
    display: none;
}
.htmx-request .htmx-indicator {
    display: inline;
}
<button hx-get="{% url 'modal' %}"
        hx-target="body"
        hx-swap="beforeend">
    Open Modal
</button>
<!-- modal.html -->
<div id="modal" class="modal">
    <div class="modal-content">
        <h2>Modal Title</h2>
        <form hx-post="{% url 'submit' %}"
              hx-target="#modal"
              hx-swap="outerHTML">
            <!-- form content -->
            <button type="submit">Submit</button>
        </form>
        <button onclick="document.getElementById('modal').remove()">
            Close
        </button>
    </div>
</div>

Infinite Scroll

<div id="items">
    {% for item in items %}
        {% include 'item.html' %}
    {% endfor %}
    
    <div hx-get="{% url 'items' %}?page={{ next_page }}"
         hx-trigger="revealed"
         hx-swap="outerHTML">
        <!-- Loading trigger -->
    </div>
</div>

Django HTMX Integration

Detecting HTMX Requests

# views.py
def my_view(request):
    if request.htmx:
        # HTMX request - return partial
        return render(request, 'partial.html', context)
    else:
        # Full page request
        return render(request, 'full.html', context)

Trigger Events

from django_htmx.http import trigger_client_event

def add_item(request):
    item = Item.objects.create(...)
    response = render(request, 'item.html', {'item': item})
    return trigger_client_event(response, 'itemAdded')
<div hx-get="{% url 'items' %}" hx-trigger="itemAdded from:body">
    <!-- Refreshes when itemAdded event fires -->
</div>

Redirects

from django_htmx.http import HttpResponseClientRedirect

def logout(request):
    auth_logout(request)
    return HttpResponseClientRedirect('/')

When to Use HTMX

Good For

Less Good For

Final Thoughts

HTMX + Django is surprisingly productive. Most “single-page app” interactions can be achieved with:

It’s not for everything, but for many apps, it’s enough—and simpler.


The best code is the code you don’t have to write.

All posts