| name | django-htmx | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | Build modern dynamic web applications with Django and htmx - partial rendering, HTMX-specific responses, and seamless frontend integration | |||||||||||||
| metadata |
|
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.
pip install django-htmxAdd to INSTALLED_APPS:
INSTALLED_APPS = [
...
"django_htmx",
]Add the middleware:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django_htmx.middleware.HtmxMiddleware", # Add this
...
]The middleware adds request.htmx to detect htmx requests:
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)The request.htmx object provides these attributes:
request.htmx- Boolean, True if request is from htmxrequest.htmx.boosted- True if request is from boosted element (hx-boost)request.htmx.current_url- Current URL in browser from HX-Current-URL headerrequest.htmx.current_url_abs_path- Absolute path form of current_urlrequest.htmx.history_restore_request- True if request is for history restorationrequest.htmx.target- Target element ID from HX-Target headerrequest.htmx.trigger- Trigger element ID from HX-Trigger headerrequest.htmx.trigger_name- Trigger element name from HX-Trigger-Name headerrequest.htmx.prompt- User response to hx-prompt attributerequest.htmx.triggering_event- Deserialized JSON from event-header extension
Load and use in templates:
{% load django_htmx %}
<!DOCTYPE html>
<html>
<head>
{% htmx_script %}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
...
</body>
</html>{% load htmx %}
<!DOCTYPE html>
<html>
<head>
{% htmx_script %} {# Automatically includes nonce #}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
...
</body>
</html>{% load django_htmx %}
<!doctype html>
<html>
<head>
{% htmx_script %}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
...
</body>
</html>Use minified=False for debugging:
{% htmx_script minified=False %}from jinja2 import Environment
from django_htmx.jinja import htmx_script
def environment(**options):
env = Environment(**options)
env.globals.update({"htmx_script": htmx_script})
return env{{ htmx_script() }}{% querystring %} (Django 4.1+) rebuilds the current query string with one
key changed — ideal for htmx filter links that re-render a list partial:
{% load django_htmx %}
<!-- keep all current filters, flip `outdoors` off -->
<a hx-get="{% querystring outdoors=None %}" hx-target="#list"
hx-push-url="true">hide outdoors</a>
<!-- paginate by changing only `page` -->
<a hx-get="{% querystring page=page.next %}" hx-target="#list">next</a>json_script safely embeds a Python dict as a <script> tag, useful for
seeding htmx-driven widgets with initial state:
{{ row.config|json_script:"row-config" }}
<script>
const cfg = JSON.parse(document.getElementById('row-config').textContent);
htmx.trigger('#row', 'config-ready', cfg);
</script>Other high-signal built-ins: urlize, linebreaksbr, date:"M j".
Triggers a client-side redirect (HX-Redirect header):
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}")
...Triggers a page reload (HX-Refresh header):
from django_htmx.http import HttpResponseClientRefresh
def partial_table_view(request):
if page_outdated(request):
return HttpResponseClientRefresh()
...Makes htmx do a client-side "boosted" request (HX-Location header):
from django_htmx.http import HttpResponseLocation
def wait_for_completion(request, action_id):
...
if action.completed:
return HttpResponseLocation(f"/action/{action.id}/completed/")
...Stops polling when using hx-trigger="every":
from django_htmx.http import HttpResponseStopPolling
def my_pollable_view(request):
if event_finished():
return HttpResponseStopPolling()
...Or use the constant directly:
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)
...Push a new URL to the browser history:
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}")
...Replace the current URL in browser history:
from django_htmx.http import replace_url
def dashboard(request):
...
response = render(request, "dashboard.html", ...)
return replace_url(response, "/dashboard/")Override the swap method:
from django.shortcuts import render
from django_htmx.http import reswap
def employee_table_row(request):
...
response = render(...)
if employee.is_boss:
reswap(response, "afterbegin")
return responseOverride the target element:
from django.shortcuts import render
from django.views.decorators.http import require_POST
from django_htmx.http import retarget
@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", ...)Override the content selection:
from django_htmx.http import reselect
def update_table(request):
response = render(request, "table.html", ...)
return reselect(response, "tbody")Trigger client-side events:
from django.shortcuts import render
from django_htmx.http import trigger_client_event
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"
)Use django-template-partials for efficient partial rendering:
pip install django-template-partials{% extends "_base.html" %}
{% load partials %}
{% block main %}
{% partialdef country-table inline %}
<table id="country-data">
...
</table>
{% endpartialdef %}
{% endblock main %}In views:
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})def partial_rendering(request):
if request.htmx:
base_template = "_partial.html"
else:
base_template = "_base.html"
return render(request, "page.html", {"base_template": base_template}){% extends base_template %}
{% block main %}
...
{% endblock %}Always include CSRF token in htmx requests:
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>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.
# 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.
Add appropriate Vary headers for cacheable responses:
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, ...)Download extensions locally (avoid CDNs):
curl -L https://unpkg.com/htmx-ext-ws/dist/ws.min.js -o static/htmx-ext-ws.min.js{% load django_htmx static %}
<!doctype html>
<html>
<head>
{% htmx_script %}
<script src="{% static 'htmx-ext-ws.min.js' %}" defer></script>
</head>
...
</html>For type-checking, extend HttpRequest:
from django.http import HttpRequest as HttpRequestBase
from django_htmx.middleware import HtmxDetails
class HttpRequest(HttpRequestBase):
htmx: HtmxDetails- 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