Skip to content
Back to skills

Django Htmx

ASecurity

Use when building dynamic Django web apps with htmx - partial rendering, HTMX responses, querystring tag, CSP

  • 21 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 2, 2026
testingjavascriptpythongojavabashdjangotestingdebugginggitfrontend

Works with

  • cli

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 3 files and shows the line behind each finding

Scanned October 2, 2026

npx -y skills add CodeAtCode/oss-ai-skills --skill django-htmx --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Django Htmx?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Django Htmx
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/codeatcode-django-htmx/badge)](https://www.skillsdirectory.com/skills/codeatcode-django-htmx)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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 (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",
    ...
]
```

## Setup

### Base Template

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 }}"}'>
    {% block content %}{% endblock %}
</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.

For debugging, use the unminified version:

```django
{% htmx_script minified=False %}
```

### Jinja2 Configuration

If using Jinja2 templates, configure the global:

```python
# 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
```

Then in templates: `{{ htmx_script() }}`

**See**: `references/csp-nonce.md` for Content-Security-Policy integration.

## 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:

- `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 (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

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):
        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

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

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

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 with `render()`:

```python
from django_htmx.http import HTMX_STOP_POLLING

def my_pollable_view(request):
    if event_finished():
        return render(request, "event-finished.html", status=HTMX_STOP_POLLING)
```

## Response Modifying Functions

These wrap an `HttpResponse` to add htmx-specific headers:

### push_url / replace_url

Update browser history without navigation:

```python
from django_htmx.http import push_url

def leaf_select(request, leaf_id):
    ...
    response = render(request, "leaf-detail.html", {"leaf": leaf})
    return push_url(response, f"/leaf/{leaf.id}")
```

Use `replace_url()` to replace current history entry instead of pushing.

### reswap / retarget / reselect

Override htmx behavior server-side:

```python
from django_htmx.http import reswap, retarget, reselect

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

### trigger_client_event

Trigger custom JavaScript events after swap:

```python
from django_htmx.http import trigger_client_event

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"
    )
```

The event name (`showConfetti`) must match a handler registered via `htmx.on()`.

## Template Tags

### querystring Filter (Django 4.1+)

Rebuild query strings for filter/pagination links:

```django
{% load django_htmx %}

<!-- Keep all filters, flip `outdoors` off -->
<a hx-get="{% querystring outdoors=None %}" hx-target="#list"
   hx-push-url="true">hide outdoors</a>

<!-- Paginate -->
<a hx-get="{% querystring page=page.next %}" hx-target="#list">next</a>
```

### json_script

Embed Python data for client-side use:

```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 with Template Partials

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 %}
```

```python
def country_listing(request):
    template = "countries.html"
    if request.htmx:
        template += "#country-table"  # Render partial only
    return render(request, template, {"countries": Country.objects.all()})
```

### Caching

Keep the cached template loader enabled (it's default, but easy to break):

```python
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",
            ]),
        ],
    },
}]
```

Without it, Django recompiles templates on every request, dominating CPU for htmx partials.

**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.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):
    ...
```

## Anti-Patterns

### 1. Business Logic in Template Fragments

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

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/

Files in this skill

  • SKILL.md14.4 KB
  • references/csp-nonce.md2.4 KB
  • references/testing-patterns.md6.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…