django-htmx · diff
v1.0.1 to v1.0.1
283 added, 229 removed. Audit B to B.
---
name: django-htmx
description: Use when building dynamic Django web apps with htmx - partial rendering, HTMX responses, querystring tag, CSP
metadata:
author: mte90
version: 1.0.1
tags:
- django
- htmx
- python
- web
- frontend
- partial-rendering
- ajax
---
# Django HTMX
Django-htmx provides seamless integration between Django and htmx for building modern, dynamic web applications without writing complex JavaScript.
**Versions**: django-htmx 1.16.0 + Django 6.0 fully compatible. Python 3.10 → 3.14 supported.
## Installation
```bash
pip install django-htmx
```
Add to `INSTALLED_APPS`:
```python
INSTALLED_APPS = [
...
"django_htmx",
]
```
- Add the middleware:
+ Add the middleware (order matters - after SessionMiddleware, before AuthenticationMiddleware):
```python
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django_htmx.middleware.HtmxMiddleware", # Required for request.htmx
"django.contrib.messages.middleware.MessageMiddleware",
- "django_htmx.middleware.HtmxMiddleware", # Add this
...
]
```
- ## Core Concepts
-
- ### Request Detection
-
- The middleware adds `request.htmx` to detect htmx requests:
-
- ```python
- from django.shortcuts import render
-
- def my_view(request):
- if request.htmx:
- template_name = "partial.html"
- else:
- template_name = "full.html"
- return render(request, template_name)
- ```
-
- ### HtmxDetails Attributes
-
- The `request.htmx` object provides these attributes:
-
- - `request.htmx` - Boolean, True if request is from htmx
- - `request.htmx.boosted` - True if request is from boosted element (hx-boost)
- - `request.htmx.current_url` - Current URL in browser from HX-Current-URL header
- - `request.htmx.current_url_abs_path` - Absolute path form of current_url
- - `request.htmx.history_restore_request` - True if request is for history restoration
- - `request.htmx.target` - Target element ID from HX-Target header
- - `request.htmx.trigger` - Trigger element ID from HX-Trigger header
- - `request.htmx.trigger_name` - Trigger element name from HX-Trigger-Name header
- - `request.htmx.prompt` - User response to hx-prompt attribute
- - `request.htmx.triggering_event` - Deserialized JSON from event-header extension
+ ## Setup
- ## Template Tags
+ ### Base Template
- Load and use in templates:
+ Load the template tag and include the htmx script once in your base template:
```django
{% load django_htmx %}
<!DOCTYPE html>
<html>
<head>
{% htmx_script %}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
- ...
- </body>
- </html>
- ```
-
- ### CSP Nonce Support (Django 6.0+)
-
- ```django
- {% load htmx %}
- <!DOCTYPE html>
- <html>
- <head>
- {% htmx_script %} {# Automatically includes nonce #}
- </head>
- <body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
- ...
+ {% block content %}{% endblock %}
</body>
</html>
```
- ### Django Templates
-
- ```django
- {% load django_htmx %}
- <!doctype html>
- <html>
- <head>
- {% htmx_script %}
- </head>
- <body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
- ...
- </body>
- </html>
- ```
+ The `hx-headers` attribute on `<body>` ensures all htmx requests carry the CSRF token. Without this, POST/PUT/DELETE requests fail with 403.
- Use `minified=False` for debugging:
+ For debugging, use the unminified version:
```django
{% htmx_script minified=False %}
```
- ### Jinja2
+ ### Jinja2 Configuration
+ If using Jinja2 templates, configure the global:
+
```python
- from jinja2 import Environment
+ # settings.py or jinja2 config
from django_htmx.jinja import htmx_script
def environment(**options):
+ from jinja2 import Environment
env = Environment(**options)
env.globals.update({"htmx_script": htmx_script})
return env
```
- ```html
- {{ htmx_script() }}
- ```
+ Then in templates: `{{ htmx_script() }}`
- ### Built-in Filters Useful with HTMX
+ **See**: `references/csp-nonce.md` for Content-Security-Policy integration.
- `{% querystring %}` (Django 4.1+) rebuilds the current query string with one
- key changed — ideal for htmx filter links that re-render a list partial:
+ ## Core Concepts
- ```django
- {% load django_htmx %}
+ ### Request Detection
- <!-- keep all current filters, flip `outdoors` off -->
- <a hx-get="{% querystring outdoors=None %}" hx-target="#list"
- hx-push-url="true">hide outdoors</a>
+ The middleware adds `request.htmx` to detect htmx requests:
- <!-- paginate by changing only `page` -->
- <a hx-get="{% querystring page=page.next %}" hx-target="#list">next</a>
+ ```python
+ from django.shortcuts import render
+
+ def my_view(request):
+ if request.htmx:
+ template_name = "partial.html"
+ else:
+ template_name = "full.html"
+ return render(request, template_name)
```
- `json_script` safely embeds a Python dict as a `<script>` tag, useful for
- seeding htmx-driven widgets with initial state:
+ ### HtmxDetails Attributes
- ```django
- {{ row.config|json_script:"row-config" }}
- <script>
- const cfg = JSON.parse(document.getElementById('row-config').textContent);
- htmx.trigger('#row', 'config-ready', cfg);
- </script>
- ```
+ The `request.htmx` object provides:
- Other high-signal built-ins: `urlize`, `linebreaksbr`, `date:"M j"`.
+ - `request.htmx` - Boolean, True if request is from htmx
+ - `request.htmx.boosted` - True if from boosted element (hx-boost)
+ - `request.htmx.current_url` - Current URL from HX-Current-URL header
+ - `request.htmx.current_url_abs_path` - Absolute path form of current_url
+ - `request.htmx.history_restore_request` - True for history restoration
+ - `request.htmx.target` - Target element ID from HX-Target header
+ - `request.htmx.trigger` - Trigger element ID from HX-Trigger header
+ - `request.htmx.trigger_name` - Trigger element name from HX-Trigger-Name header
+ - `request.htmx.prompt` - User response to hx-prompt attribute
+ - `request.htmx.triggering_event` - Deserialized JSON from event-header extension
- ## HTTP Response Classes
+ ## HTTP Response Classes (Decision Guide)
+ Choose the right response type based on what should happen on the client:
+
+ | Response Type | When to Use | What Breaks If Wrong |
+ |---------------|-------------|---------------------|
+ | `HttpResponse` (normal) | Full page reload needed, or swapping entire document | Using htmx-specific responses here causes no-op or unexpected behavior |
+ | `HttpResponseClientRedirect` | Navigate to different URL without full reload | Using plain `HttpResponseRedirect` leaves stale DOM; user sees old content |
+ | `HttpResponseClientRefresh` | Force full page reload (stale session, permission change) | Using redirect instead loses form data; using `HttpResponse` doesn't refresh |
+ | `HttpResponseLocation` | "Boosted" navigation to new URL | Using redirect causes full reload; using `HttpResponse` shows wrong URL |
+ | `HttpResponseStopPolling` | End polling loop (event finished, error unrecoverable) | Omitting this keeps polling, wasting resources |
+ | Plain `HttpResponse` + `HX-Trigger` | Update fragment and trigger client event | Without trigger, client doesn't know to update related UI (e.g., badge count) |
+ | Plain `HttpResponse` + `HX-Redirect` header | Client-side redirect from server logic | Using `HttpResponseClientRedirect` directly is cleaner; manual header is for custom logic |
+
### HttpResponseClientRedirect
- Triggers a client-side redirect (HX-Redirect header):
+ Use for navigation that should update browser history without full reload:
```python
from django_htmx.http import HttpResponseClientRedirect
def sensitive_view(request):
if not sudo_mode.active(request):
- next_url = request.htmx.current_url_abs_path or ""
- return HttpResponseClientRedirect(f"/activate-sudo/?next={next_url}")
+ return HttpResponseClientRedirect("/activate-sudo/")
...
```
+ **What breaks**: A plain `HttpResponseRedirect` causes htmx to follow the redirect as a normal request, which may swap the wrong fragment or leave the original DOM intact.
+
### HttpResponseClientRefresh
- Triggers a page reload (HX-Refresh header):
+ Force a full page reload:
```python
from django_htmx.http import HttpResponseClientRefresh
def partial_table_view(request):
if page_outdated(request):
return HttpResponseClientRefresh()
...
```
+ **What breaks**: Without this, the client keeps showing stale data. Use when session expired, permissions changed, or cache invalidation occurred.
+
### HttpResponseLocation
- Makes htmx do a client-side "boosted" request (HX-Location header):
+ Trigger client-side "boosted" navigation (hx-boost behavior):
```python
from django_htmx.http import HttpResponseLocation
def wait_for_completion(request, action_id):
...
if action.completed:
return HttpResponseLocation(f"/action/{action.id}/completed/")
...
```
+ **What breaks**: Using a redirect causes a full page reload, losing the htmx benefits.
+
### HttpResponseStopPolling
- Stops polling when using hx-trigger="every":
+ End a polling loop:
```python
from django_htmx.http import HttpResponseStopPolling
def my_pollable_view(request):
if event_finished():
return HttpResponseStopPolling()
- ...
+ return render(request, "status.html", {"status": "running"})
```
- Or use the constant directly:
+ Or use the constant with `render()`:
```python
from django_htmx.http import HTMX_STOP_POLLING
- from django.shortcuts import render
def my_pollable_view(request):
if event_finished():
return render(request, "event-finished.html", status=HTMX_STOP_POLLING)
- ...
```
## Response Modifying Functions
- ### push_url
+ These wrap an `HttpResponse` to add htmx-specific headers:
- Push a new URL to the browser history:
+ ### push_url / replace_url
+ Update browser history without navigation:
+
```python
from django_htmx.http import push_url
- def leaf(request, leaf_id):
- ...
- if leaf is None:
- response = branch(request, branch=leaf.branch)
- return push_url(response, f"/branch/{leaf.branch.id}")
+ def leaf_select(request, leaf_id):
...
+ response = render(request, "leaf-detail.html", {"leaf": leaf})
+ return push_url(response, f"/leaf/{leaf.id}")
```
- ### replace_url
+ Use `replace_url()` to replace current history entry instead of pushing.
- Replace the current URL in browser history:
+ ### reswap / retarget / reselect
+ Override htmx behavior server-side:
+
```python
- from django_htmx.http import replace_url
+ from django_htmx.http import reswap, retarget, reselect
- def dashboard(request):
- ...
- response = render(request, "dashboard.html", ...)
- return replace_url(response, "/dashboard/")
+ def conditional_swap(request):
+ response = render(request, "row.html", {"row": row})
+ if row.is_special:
+ reswap(response, "afterbegin") # Override hx-swap
+ retarget(response, "#special-container") # Override hx-target
+ return reselect(response, ".data-row") # Override CSS selector
```
- ### reswap
+ ### trigger_client_event
- Override the swap method:
+ Trigger custom JavaScript events after swap:
```python
- from django.shortcuts import render
- from django_htmx.http import reswap
+ from django_htmx.http import trigger_client_event
- def employee_table_row(request):
- ...
- response = render(...)
- if employee.is_boss:
- reswap(response, "afterbegin")
- return response
+ def end_of_process(request):
+ response = render(request, "done.html")
+ return trigger_client_event(
+ response,
+ "showConfetti",
+ {"colours": ["purple", "red", "pink"]},
+ after="swap", # "receive", "settle", or "swap"
+ )
```
- ### retarget
-
- Override the target element:
+ The event name (`showConfetti`) must match a handler registered via `htmx.on()`.
- ```python
- from django.shortcuts import render
- from django.views.decorators.http import require_POST
- from django_htmx.http import retarget
+ ## Template Tags
- @require_POST
- def add_widget(request):
- ...
- if form.is_valid():
- response = render(request, "widget-table.html", ...)
- return retarget(response, "#widgets")
- return render(request, "widget-table-row.html", ...)
- ```
+ ### querystring Filter (Django 4.1+)
- ### reselect
+ Rebuild query strings for filter/pagination links:
- Override the content selection:
+ ```django
+ {% load django_htmx %}
- ```python
- from django_htmx.http import reselect
+ <!-- Keep all filters, flip `outdoors` off -->
+ <a hx-get="{% querystring outdoors=None %}" hx-target="#list"
+ hx-push-url="true">hide outdoors</a>
- def update_table(request):
- response = render(request, "table.html", ...)
- return reselect(response, "tbody")
+ <!-- Paginate -->
+ <a hx-get="{% querystring page=page.next %}" hx-target="#list">next</a>
```
- ### trigger_client_event
-
- Trigger client-side events:
+ ### json_script
- ```python
- from django.shortcuts import render
- from django_htmx.http import trigger_client_event
+ Embed Python data for client-side use:
- def end_of_long_process(request):
- response = render(request, "end-of-long-process.html")
- return trigger_client_event(
- response,
- "showConfetti",
- {"colours": ["purple", "red", "pink"]},
- after="swap", # "receive", "settle", or "swap"
- )
+ ```django
+ {{ row.config|json_script:"row-config" }}
+ <script>
+ const cfg = JSON.parse(document.getElementById('row-config').textContent);
+ htmx.trigger('#row', 'config-ready', cfg);
+ </script>
```
## Best Practices
- ### Partial Rendering
+ ### Partial Rendering with Template Partials
- Use django-template-partials for efficient partial rendering:
+ Use `django-template-partials` for efficient fragment rendering:
```bash
pip install django-template-partials
```
```django
{% extends "_base.html" %}
{% load partials %}
{% block main %}
{% partialdef country-table inline %}
<table id="country-data">
- ...
+ {% for country in countries %}
+ <tr><td>{{ country.name }}</td></tr>
+ {% endfor %}
</table>
{% endpartialdef %}
{% endblock main %}
```
- In views:
-
```python
def country_listing(request):
- template_name = "countries.html"
- if request.htmx:
- template_name += "#country-table"
-
- countries = Country.objects.all()
- return render(request, template_name, {"countries": countries})
- ```
-
- ### Swapping Base Template
-
- ```python
- def partial_rendering(request):
+ template = "countries.html"
if request.htmx:
- base_template = "_partial.html"
- else:
- base_template = "_base.html"
-
- return render(request, "page.html", {"base_template": base_template})
- ```
-
- ```django
- {% extends base_template %}
- {% block main %}
- ...
- {% endblock %}
- ```
-
- ### CSRF Protection
-
- Always include CSRF token in htmx requests:
-
- ```html
- <body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
+ template += "#country-table" # Render partial only
+ return render(request, template, {"countries": Country.objects.all()})
```
- ### Caching with HTMX
+ ### Caching
- Keep the **cached template loader** on (`django.template.loaders.cached.Loader`).
- It is on by default, but it is easy to disable by accident while tweaking
- `TEMPLATES` — without it Django recompiles every template on every render, which
- dominates CPU for server-rendered htmx partials.
+ Keep the cached template loader enabled (it's default, but easy to break):
```python
- # settings.py — the safe shape. Wrap app_dirs in cached.Loader.
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"OPTIONS": {
"loaders": [
("django.template.loaders.cached.Loader", [
"django.template.loaders.app_directories.Loader",
"django.template.loaders.filesystem.Loader",
]),
],
},
}]
```
- If a CPU profile (`py-spy record -o profile.svg --pid <django_pid>`) shows
- `django.template.base.compile` near the top, the cached loader is off.
+ Without it, Django recompiles templates on every request, dominating CPU for htmx partials.
- Add appropriate Vary headers for cacheable responses:
+ **Diagnosis**: If `py-spy` shows `django.template.base.compile` at the top, the cache is off.
+ ### Vary Headers
+
+ Tell caches that content differs by htmx request:
+
```python
- from django.shortcuts import render
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_headers
@cache_control(max_age=300)
@vary_on_headers("HX-Request")
def my_view(request):
- if request.htmx:
- template_name = "partial.html"
- else:
- template_name = "complete.html"
- return render(request, template_name, ...)
+ ...
```
- ### HTMX Extensions
+ ## Anti-Patterns
- Download extensions locally (avoid CDNs):
+ ### 1. Business Logic in Template Fragments
- ```bash
- curl -L https://unpkg.com/htmx-ext-ws/dist/ws.min.js -o static/htmx-ext-ws.min.js
+ **Wrong**: A partial that needs data the parent page didn't load.
+
+ ```django
+ <!-- BAD: template tries to access data not in context -->
+ {% partialdef user-stats %}
+ {{ user.profile.score }} {# user not passed to partial #}
+ {% endpartialdef %}
```
+ **Fix**: Ensure the view serving the fragment has all required context.
+
+ ### 2. Returning JSON to hx-get
+
+ **Wrong**: htmx expects HTML by default.
+
+ ```python
+ # BAD: htmx silently ignores JSON responses
+ def data_view(request):
+ return JsonResponse({"data": items})
+ ```
+
+ **Symptom**: No error, but nothing updates in the DOM.
+
+ **Fix**: Return HTML fragments, or use `hx-trigger` with JavaScript to handle JSON.
+
+ ### 3. Missing hx-swap-oob for Out-of-Band Updates
+
+ **Wrong**: Updating a badge without telling htmx where to put it.
+
+ ```python
+ # BAD: client doesn't know to update badge
+ return render(request, "order-status.html", {"status": "shipped"})
+ ```
+
+ **Fix**: Use `hx-swap-oob` to update multiple fragments:
+
```django
- {% load django_htmx static %}
- <!doctype html>
- <html>
- <head>
- {% htmx_script %}
- <script src="{% static 'htmx-ext-ws.min.js' %}" defer></script>
- </head>
- ...
- </html>
+ <!-- In the fragment response -->
+ <span id="badge" hx-swap-oob="true">{{ new_count }}</span>
```
+ ### 4. Expensive Polling with every="1s"
+
+ **Wrong**: Self-inflicted load generator.
+
+ ```django
+ <div hx-get="/status" hx-trigger="every 1s">...</div>
+ ```
+
+ **Fix**: Use longer intervals, or WebSockets for real-time:
+
+ ```django
+ <div hx-get="/status" hx-trigger="every 5s">...</div>
+ ```
+
+ Or load the `ws` extension for push-based updates.
+
+ ### 5. Missing CSRF Token
+
+ **Wrong**: POST requests fail with 403.
+
+ ```django
+ <!-- BAD: no CSRF token -->
+ <body>
+ <form hx-post="/submit">...</form>
+ </body>
+ ```
+
+ **Symptom**: 403 errors that look like template bugs.
+
+ **Fix**: Include token in `hx-headers` on `<body>` (see Setup section) or per-form:
+
+ ```django
+ <form hx-post="/submit" hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
+ ```
+
+ ### 6. Forgetting HX-Redirect for Client-Side Redirects
+
+ **Wrong**: Using Django's `HttpResponseRedirect` in htmx context.
+
+ ```python
+ # BAD: causes full page reload
+ return HttpResponseRedirect("/done/")
+ ```
+
+ **Fix**: Use `HttpResponseClientRedirect` for htmx-aware redirects.
+
+ ## Testing
+
+ ### What You Can Test Server-Side
+
+ **Fragment content**:
+
+ ```python
+ from django.test import TestCase
+
+ class HtmxTests(TestCase):
+ def test_fragment_content(self):
+ response = self.client.get(
+ "/partial/",
+ HTTP_HX_REQUEST="true", # Sets HX-Request header
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Expected fragment content")
+ ```
+
+ **HX-Response headers**:
+
+ ```python
+ def test_redirect_header(self):
+ response = self.client.post("/sensitive-action/")
+ self.assertEqual(response["HX-Redirect"], "/activate-sudo/")
+ ```
+
+ **Out-of-band swaps**:
+
+ ```python
+ def test_oob_swap(self):
+ response = self.client.post("/update-badge/")
+ # Check the response contains hx-swap-oob attribute
+ self.assertIn(b'hx-swap-oob="true"', response.content)
+ ```
+
+ **CSRF handling**:
+
+ ```python
+ def test_csrf_required(self):
+ response = self.client.post("/action/", HTTP_HX_REQUEST="true")
+ self.assertEqual(response.status_code, 403) # No CSRF token
+
+ response = self.client.post(
+ "/action/",
+ HTTP_HX_REQUEST="true",
+ HTTP_X_CSRFTOKEN=self.client.cookies["csrftoken"].value,
+ )
+ self.assertEqual(response.status_code, 200)
+ ```
+
+ ### What You Cannot Test Server-Side
+
+ - **Actual DOM swaps**: Django tests don't render HTML in the browser
+ - **hx-trigger timing**: Polling intervals, debouncing
+ - **Client-side event handlers**: `htmx.on()` handlers triggered by `HX-Trigger`
+ - **hx-swap behavior**: How content is actually inserted (use Playwright/Cypress for this)
+
+ **For client-side behavior**: Use end-to-end tests with Playwright or Cypress.
+
## Type Checking
- For type-checking, extend HttpRequest:
+ Extend `HttpRequest` for type hints:
```python
from django.http import HttpRequest as HttpRequestBase
from django_htmx.middleware import HtmxDetails
class HttpRequest(HttpRequestBase):
htmx: HtmxDetails
```
+ ## Deep Dives
+
+ Load these reference files for detailed guidance:
+
+ - `references/csp-nonce.md` - Content-Security-Policy nonce integration (when strict CSP breaks htmx)
+ - `references/testing-patterns.md` - Extended testing strategies and e2e setup
+
## References
- **Official Documentation**: https://django-htmx.readthedocs.io/
- **GitHub Repository**: https://github.com/adamchainz/django-htmx
- **htmx Reference**: https://htmx.org/reference/
- **jvns.ca – More nice Django things**: https://jvns.ca/blog/2026/07/21/more-nice-django-things/
- - **HN discussion**: https://news.ycombinator.com/item?id=48997828
+