HTMX и Django: интерактивность без SPA
Как подключить HTMX к Django: атрибуты hx-get, hx-post и hx-swap, CSRF, пакет django-htmx, партиалы Django 6, живой поиск, бесконечная лента и формы.
Содержание · 12 разделов
- Как работает HTMX
- Какую версию HTMX выбрать
- Подключение HTMX к Django
- Через CDN
- Из статических файлов
- Через пакет django-htmx
- Основные атрибуты HTMX
- hx-target: куда вставить ответ
- hx-swap: как вставить ответ
- hx-trigger: когда отправить запрос
- CSRF-защита в запросах HTMX
- Пакет django-htmx: request.htmx и ответы
- Объект request.htmx
- HttpResponseClientRedirect и другие ответы
- Частичные шаблоны: страница и фрагмент из одного view
- Отдельные шаблоны через include
- Партиалы Django 6.0: partialdef
- Заголовок Vary
- Рабочий пример: каталог книг
- Живой поиск
- Бесконечная прокрутка
- Inline-редактирование и удаление
- Отправка формы с валидацией
- Проверка через тестовый клиент Django
- Частые ошибки
- Когда HTMX не подходит
- Частые вопросы
- Обязательно ли устанавливать django-htmx?
- Как показать индикатор загрузки?
- Как обновить несколько частей страницы одним ответом?
- Как передать в запрос дополнительные данные?
- Как перейти на htmx 4?
- Итог
HTMX — небольшая JavaScript-библиотека (около 16 КБ в gzip), которая позволяет любому HTML-элементу отправить запрос на сервер и заменить часть страницы полученным HTML. Связка htmx + Django работает так: Django-view рендерит фрагмент шаблона, а HTMX вставляет его в нужное место без перезагрузки страницы. Отдельный фронтенд на React или Vue, JSON API и сборщик не нужны — интерактивность описывается атрибутами прямо в шаблоне:
<button hx-get="{% url 'hello' %}" hx-target="#greeting">Поздороваться</button>
<div id="greeting"></div>
from django.http import HttpResponse
def hello(request):
return HttpResponse("<p>Привет от Django!</p>")
По клику HTMX отправит GET-запрос и вставит ответ внутрь #greeting. Ниже — подключение HTMX, главные атрибуты, CSRF, пакет django-htmx, партиалы Django 6.0 и рабочий пример с живым поиском, бесконечной прокруткой, inline-редактированием и формой с валидацией. Примеры проверены на Django 6.1, django-htmx 1.29 и htmx 2.
Как работает HTMX
Обычный сайт на Django отдаёт страницу целиком: ссылка или форма — запрос — новая страница. HTMX снимает три ограничения HTML: запрос может отправить любой элемент, а не только ссылка и форма; запускать его может любое событие (ввод текста, прокрутка, таймер); ответ заменяет часть страницы, а не всю.
Цикл одного взаимодействия:
- Пользователь кликает, вводит текст или прокручивает страницу.
- HTMX отправляет AJAX-запрос с заголовком
HX-Request: true. - Django-view обрабатывает его как обычный запрос и возвращает HTML-фрагмент.
- HTMX вставляет фрагмент в элемент из
hx-targetспособом изhx-swap.
Состояние живёт на сервере, валидация остаётся в формах Django, шаблоны — единственное место, где описан внешний вид. Такой подход называют «HTML по сети» (HTML over the wire): сервер отдаёт готовую разметку вместо JSON.
Какую версию HTMX выбрать
В августе 2026 года вышел htmx 4.0, но в npm он помечен тегом next, а latest остаётся у htmx 2 (сейчас 2.0.11). Разработчики обещают сделать 4.x основной в 2027 году и поддерживать 2.x без ограничения срока. Версии 3 нет — проект перешёл с 2 сразу на 4.
| Что изменилось | htmx 2 | htmx 4 |
|---|---|---|
| Наследование атрибутов | Неявное: атрибут родителя действует на потомков | Только явное: hx-headers:inherited, hx-confirm:inherited |
| Ответы 4xx и 5xx | Не вставляются в страницу | Вставляются; не вставляются только 204 и 304 |
| Имена событий | htmx:afterRequest, htmx:beforeSwap | htmx:after:request, htmx:before:swap |
| Блокировка кнопки на время запроса | hx-disabled-elt | hx-disable (прежний hx-disable стал hx-ignore) |
| История браузера | Снимки страниц в localStorage | Повторный запрос страницы при возврате назад |
| Внутренний транспорт | XMLHttpRequest | fetch() |
Для нового проекта подойдут обе версии. htmx 2 используется по умолчанию в django-htmx и почти во всех руководствах, поэтому примеры ниже написаны для него. Что поменять при переходе на 4, — в разделе частых вопросов в конце статьи.
Подключение HTMX к Django
HTMX — один JS-файл без зависимостей. Подключите его в базовом шаблоне, от которого наследуются остальные страницы.
Через CDN
Самый быстрый способ для прототипа — тег script с указанием точной версии и хешем целостности:
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
crossorigin="anonymous"></script>
Не подключайте адрес без версии вида unpkg.com/htmx.org: он ведёт на последнюю версию, и обновление до htmx 4 однажды сломает сайт.
Из статических файлов
Для продакшена надёжнее хранить файл в проекте: сайт не зависит от CDN, а Content Security Policy (CSP) не нужно открывать для внешнего домена. Скачайте файл в папку статики:
mkdir -p static/js
curl -L -o static/js/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js
New-Item -ItemType Directory -Force static\js
Invoke-WebRequest https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js -OutFile static\js\htmx.min.js
И подключите через тег static:
{% load static %}
<script src="{% static 'js/htmx.min.js' %}" defer></script>
Через пакет django-htmx
django-htmx — пакет Адама Джонсона с middleware, классами ответов и копией htmx внутри. Версия 1.29 поддерживает Django 5.2, 6.0 и 6.1 и Python 3.10+. Установите его в виртуальное окружение проекта:
py -m pip install django-htmx
python3 -m pip install django-htmx
Добавьте приложение и middleware в settings.py:
INSTALLED_APPS = [
# ...
"django_htmx",
]
MIDDLEWARE = [
# ...
"django_htmx.middleware.HtmxMiddleware",
]
Теперь скрипт подключается тегом htmx_script:
{% load django_htmx %}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{% block title %}Книги{% endblock %}</title>
{% htmx_script %}
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% block content %}{% endblock %}
</body>
</html>
Тег выводит встроенную в пакет htmx 2 (в django-htmx 1.29 — версия 2.0.10) с атрибутом defer. При DEBUG = True он добавляет отладочный скрипт: если сервер ответил ошибкой 400, 403, 404 или 500, скрипт покажет страницу ошибки Django на весь экран. Без него htmx 2 молча проигнорирует такой ответ. Параметр version=4 подключает htmx 4, minified=False — неминифицированную сборку для отладки:
<script src="/static/django_htmx/htmx-2.min.js" defer></script><script src="/static/django_htmx/django-htmx.js" data-debug="True" defer></script>
Атрибут hx-headers на body нужен для CSRF — о нём отдельный раздел ниже.
Основные атрибуты HTMX
| Атрибут | Что делает |
|---|---|
hx-get, hx-post | Отправить GET или POST на указанный URL |
hx-put, hx-patch, hx-delete | Запросы с другими HTTP-методами |
hx-target | Куда вставить ответ (CSS-селектор или относительный селектор) |
hx-swap | Как вставить ответ: заменить содержимое, элемент целиком, добавить в конец |
hx-trigger | Какое событие запускает запрос |
hx-push-url | Записать URL запроса в адресную строку и историю браузера |
hx-indicator | Элемент-индикатор загрузки |
hx-confirm | Спросить подтверждение перед запросом |
hx-sync | Что делать, если новый запрос стартует до окончания предыдущего |
hx-disabled-elt | Заблокировать кнопку на время запроса |
hx-vals, hx-include | Добавить к запросу значения или поля других элементов |
hx-boost | Превратить обычные ссылки и формы внутри элемента в AJAX-переходы |
hx-swap-oob | Обновить элемент вне hx-target тем же ответом |
Форма с hx-post отправляет все свои поля, как обычная. Кнопка или ссылка с hx-post вне формы отправляет только значения из hx-vals, hx-include и атрибута name самого элемента.
hx-target: куда вставить ответ
По умолчанию ответ вставляется в сам элемент, который отправил запрос. hx-target принимает CSS-селектор (#results, .list) или относительный селектор:
this— сам элемент;closest tr— ближайший родитель-строка таблицы;find .errors— первый потомок с классомerrors;next div,previous div— соседний элемент.
Относительные селекторы избавляют от уникальных id в повторяющихся блоках: одна и та же строка таблицы работает для любой записи.
hx-swap: как вставить ответ
| Значение | Результат | Пример применения |
|---|---|---|
innerHTML (по умолчанию) | Заменяет содержимое цели | Обновить блок результатов поиска |
outerHTML | Заменяет цель целиком | Строка таблицы ↔ форма редактирования |
beforeend, afterbegin | Добавляет в конец или начало цели | Новый комментарий в ленте |
beforebegin, afterend | Вставляет перед целью или после неё | Следующая порция строк |
delete | Удаляет цель независимо от ответа | Удаление записи |
none | Ничего не вставляет, обрабатывает только заголовки ответа | Действие, которое только шлёт событие |
hx-trigger: когда отправить запрос
Без hx-trigger поля input, textarea и select отправляют запрос по событию change, форма — по submit, остальные элементы — по click. Свой триггер задаётся событием и модификаторами:
<input name="q" hx-get="{% url 'book_list' %}"
hx-trigger="input changed delay:300ms" hx-target="#results">
<div hx-get="{% url 'stats' %}" hx-trigger="load, every 30s"></div>
<tr hx-get="?page=2" hx-trigger="revealed" hx-swap="outerHTML"></tr>
changed— запрос уходит, только если значение поля изменилось;delay:300ms— ждёт паузу во вводе (debounce),throttle:1s— не чаще раза в секунду;once— сработать один раз;load— при появлении элемента в DOM,every 30s— опрос по таймеру;revealed— элемент прокрутили в видимую область окна; внутри блока с собственной прокруткой используйтеintersect once.
CSRF-защита в запросах HTMX
Django проверяет CSRF-токен у всех POST, PUT, PATCH и DELETE. Токен принимается из поля формы csrfmiddlewaretoken или из заголовка X-CSRFToken. Самый надёжный вариант — один раз передать заголовок на body:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
В htmx 2 атрибуты наследуются, поэтому заголовок получат все запросы на странице — и формы, и кнопки с hx-delete вне форм. Обратите внимание: здесь нужна переменная {{ csrf_token }}. Тег {% csrf_token %} выводит скрытое поле input и в атрибут не подходит. Если вы меняли настройку CSRF_HEADER_NAME, используйте её имя заголовка.
Форма с hx-post и тегом {% csrf_token %} внутри тоже пройдёт проверку: HTMX отправит скрытое поле вместе с остальными. Тег стоит оставлять в формах в любом случае — без него не сработает обычная отправка, если JavaScript не загрузился.
Важно: в htmx 4 атрибуты больше не наследуются по умолчанию. На
bodyнужно писатьhx-headers:inherited='{"X-CSRFToken": "{{ csrf_token }}"}', иначе все POST- и DELETE-запросы получат 403.
Без токена Django отвечает 403 Forbidden, а htmx 2 не вставляет ответы с кодами 4xx — кнопка просто «ничего не делает». Ошибку видно только во вкладке Network инструментов разработчика. Ещё один случай: после входа через HTMX-запрос Django меняет CSRF-токен, а на странице остаётся старый. Поэтому после входа и выхода перезагружайте страницу — ответом HttpResponseClientRedirect или HttpResponseClientRefresh (о них — ниже).
Пакет django-htmx: request.htmx и ответы
HTMX можно использовать и без пакета: всё, что нужно серверу, приходит в заголовках, например request.headers.get("HX-Request") == "true". django-htmx делает эту работу удобнее.
Объект request.htmx
HtmxMiddleware добавляет к запросу атрибут request.htmx. В условии он истинен, только если запрос отправил HTMX:
def book_list(request):
if request.htmx:
... # вернуть фрагмент
... # вернуть страницу целиком
| Атрибут | Заголовок | Что содержит |
|---|---|---|
bool(request.htmx) | HX-Request | Запрос отправил HTMX |
request.htmx.boosted | HX-Boosted | Переход по ссылке или форме с hx-boost |
request.htmx.target | HX-Target | id целевого элемента |
request.htmx.trigger, trigger_name | HX-Trigger, HX-Trigger-Name | id и name элемента-источника (только htmx 2) |
request.htmx.current_url | HX-Current-URL | Адрес страницы в браузере |
request.htmx.history_restore_request | HX-History-Restore-Request | Восстановление истории после промаха кеша |
request.htmx.prompt | HX-Prompt | Ответ пользователя на hx-prompt |
request.htmx.source, request_type | HX-Source, HX-Request-Type | Источник запроса и тип full/partial (только htmx 4) |
HttpResponseClientRedirect и другие ответы
Обычный redirect() в ответ на HTMX-запрос не сработает как ожидается: браузер молча выполнит редирект внутри AJAX-запроса, и HTMX вставит новую страницу целиком в маленький блок. Чтобы браузер перешёл по адресу полностью, верните HttpResponseClientRedirect — он отвечает кодом 200 с заголовком HX-Redirect:
from django.urls import reverse
from django_htmx.http import HttpResponseClientRedirect
return HttpResponseClientRedirect(reverse("book_list"))
| Класс или функция | Заголовок или код | Действие в браузере |
|---|---|---|
HttpResponseClientRedirect(url) | HX-Redirect | Полный переход на url |
HttpResponseClientRefresh() | HX-Refresh: true | Перезагрузка текущей страницы |
HttpResponseLocation(url) | HX-Location | AJAX-переход без перезагрузки, как с hx-boost |
HttpResponseStopPolling() | код 286 | Остановить опрос every Ns |
trigger_client_event(response, name) | HX-Trigger | Вызвать событие в браузере, на которое подписаны другие блоки |
push_url(response, url) | HX-Push-Url | Записать адрес в историю браузера |
retarget(response, selector), reswap(response, method) | HX-Retarget, HX-Reswap | Изменить цель и способ вставки из view |
trigger_client_event связывает независимые блоки страницы: view добавления книги отправляет событие booksChanged, а счётчик с hx-trigger="booksChanged from:body" сам запрашивает новое значение.
Частичные шаблоны: страница и фрагмент из одного view
Главный приём HTMX в Django — один view, который отдаёт полную страницу при обычном переходе и только фрагмент при HTMX-запросе. Так страница работает по прямой ссылке, после перезагрузки и у поисковых роботов.
Отдельные шаблоны через include
Фрагмент выносят в отдельный файл и подключают через {% include %} в полный шаблон. View выбирает шаблон по request.htmx:
template = "books/_rows.html" if request.htmx else "books/book_list.html"
return render(request, template, context)
Способ работает в любой версии Django, но фрагменты разбегаются по десяткам мелких файлов.
Партиалы Django 6.0: partialdef
В Django 6.0 в язык шаблонов встроены партиалы — именованные фрагменты внутри одного шаблона. Фрагмент объявляют тегом {% partialdef имя %}, выводят тегом {% partial имя %}, а из view обращаются к нему по имени "шаблон.html#имя". Подробнее о синтаксисе — в статье про шаблоны Django.
{% partialdef book-form inline %}
<form>...</form>
{% endpartialdef %}
template = "books/book_form.html"
if request.htmx:
template += "#book-form" # только форма, без base.html
Аргумент inline выводит партиал на месте объявления при рендере полной страницы. Без него партиал только объявляется, а выводится тегом {% partial %} — так его можно вставить в цикл. На Django 5.2 LTS те же теги даёт пакет django-template-partials: установите его, добавьте "template_partials" в INSTALLED_APPS и {% load partials %} в шаблон.
Заголовок Vary
Один URL теперь отдаёт два разных ответа в зависимости от заголовка HX-Request. Об этом надо сообщить браузеру и кешам заголовком Vary, иначе возможна неприятная ошибка: пользователь уходит со страницы, нажимает «Назад» — и браузер показывает из кеша голый фрагмент без стилей и разметки. Помогает декоратор vary_on_headers:
from django.views.decorators.vary import vary_on_headers
@vary_on_headers("HX-Request")
def book_list(request):
...
Если на сайте есть hx-boost, проверяйте if request.htmx and not request.htmx.boosted: boosted-переход тоже отправляет HX-Request: true, но ждёт страницу целиком.
Рабочий пример: каталог книг
Соберём страницу каталога: поиск по мере ввода, подгрузка при прокрутке, редактирование строки на месте, удаление и форма добавления с серверной валидацией. Нужны проект с приложением books (как его создать, — в статье про startapp), установленный django-htmx и базовый шаблон base.html из раздела о подключении.
Модель и форма:
# books/models.py
from django.db import models
class Book(models.Model):
title = models.CharField("Название", max_length=200)
author = models.CharField("Автор", max_length=100)
year = models.PositiveIntegerField("Год издания")
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
# books/forms.py
from datetime import date
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "author", "year"]
def clean_year(self):
year = self.cleaned_data["year"]
if year > date.today().year:
raise forms.ValidationError("Год издания не может быть в будущем.")
return year
Маршруты:
# config/urls.py
from django.urls import path
from books import views
urlpatterns = [
path("books/", views.book_list, name="book_list"),
path("books/new/", views.book_create, name="book_create"),
path("books/<int:pk>/", views.book_row, name="book_row"),
path("books/<int:pk>/edit/", views.book_edit, name="book_edit"),
path("books/<int:pk>/delete/", views.book_delete, name="book_delete"),
]
Живой поиск
Список, поиск и подгрузка страниц обслуживает один view. При HTMX-запросе он рендерит только партиал rows — строки таблицы:
# books/views.py
from django.core.paginator import Paginator
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django.views.decorators.vary import vary_on_headers
from django_htmx.http import HttpResponseClientRedirect
from .forms import BookForm
from .models import Book
@vary_on_headers("HX-Request")
def book_list(request):
q = request.GET.get("q", "").strip()
books = Book.objects.all()
if q:
books = books.filter(title__icontains=q)
page = Paginator(books, 20).get_page(request.GET.get("page"))
template = "books/book_list.html"
if request.htmx:
template += "#rows" # только строки таблицы
return render(request, template, {"page": page, "q": q})
Шаблон templates/books/book_list.html объявляет два партиала: row — одна строка, rows — все строки страницы плюс строка-«датчик» для подгрузки следующей порции:
{% extends "base.html" %}
{% partialdef row %}
<tr id="book-{{ book.pk }}">
<td>{{ book.title }}</td>
<td>{{ book.author }}</td>
<td>{{ book.year }}</td>
<td>
<button hx-get="{% url 'book_edit' book.pk %}"
hx-target="closest tr" hx-swap="outerHTML">Изменить</button>
<button hx-delete="{% url 'book_delete' book.pk %}"
hx-confirm="Удалить книгу?"
hx-target="closest tr" hx-swap="outerHTML">Удалить</button>
</td>
</tr>
{% endpartialdef %}
{% partialdef rows %}
{% for book in page %}
{% partial row %}
{% empty %}
<tr><td colspan="4">Ничего не найдено</td></tr>
{% endfor %}
{% if page.has_next %}
<tr hx-get="{% url 'book_list' %}{% querystring page=page.next_page_number %}"
hx-trigger="revealed" hx-swap="outerHTML">
<td colspan="4">Загружаем ещё…</td>
</tr>
{% endif %}
{% endpartialdef %}
{% block content %}
<input type="search" name="q" value="{{ q }}" placeholder="Поиск по названию"
hx-get="{% url 'book_list' %}"
hx-trigger="input changed delay:300ms, search"
hx-target="#book-rows"
hx-sync="this:replace"
hx-push-url="true">
<table>
<tbody id="book-rows">
{% partial rows %}
</tbody>
</table>
{% endblock %}
Что делает поле поиска:
input changed delay:300ms— запрос уходит через 300 мс после последнего нажатия и только если текст изменился;search— дополнительный триггер на очистку поля крестиком (событие поддерживают Chrome и Safari);hx-sync="this:replace"— новый запрос отменяет незавершённый, и медленный ответ на «dj» не перезапишет результаты для «django»;hx-push-url="true"— адрес/books/?q=djangoпопадает в адресную строку: результатом можно поделиться, а после перезагрузки view отдаст полную страницу с тем же поиском.
Совет: в SQLite
icontainsне различает регистр только для латиницы. Запрос «мастерства» найдёт «Python. К вершинам мастерства», а «Мастерства» — нет. В PostgreSQL поиск без учёта регистра работает и для кириллицы.
Бесконечная прокрутка
Последняя строка каждой порции — «датчик» с hx-trigger="revealed". Когда пользователь докручивает до неё, HTMX запрашивает следующую страницу, а hx-swap="outerHTML" заменяет датчик новыми строками и новым датчиком. На последней странице page.has_next ложно, датчик не выводится, и подгрузка прекращается.
Тег {% querystring %} (Django 5.1+) берёт текущие GET-параметры и меняет только page. Без него вторая порция потеряет фильтр поиска и начнёт подгружать все книги подряд. Если строки таблицы лежат в блоке с собственной прокруткой, замените revealed на intersect once.
На больших таблицах Paginator с глубокими страницами замедляется из-за OFFSET. Тогда передавайте в датчик id последней записи и выбирайте следующую порцию через filter(pk__gt=last_id).
Inline-редактирование и удаление
Кнопка «Изменить» запрашивает форму и заменяет ею строку (hx-target="closest tr", hx-swap="outerHTML"). После сохранения view возвращает партиал row — строка снова становится обычной:
def book_row(request, pk):
book = get_object_or_404(Book, pk=pk)
return render(request, "books/book_list.html#row", {"book": book})
@require_http_methods(["GET", "POST"])
def book_edit(request, pk):
book = get_object_or_404(Book, pk=pk)
form = BookForm(request.POST or None, instance=book)
if request.method == "POST" and form.is_valid():
form.save()
return render(request, "books/book_list.html#row", {"book": book})
return render(request, "books/book_edit_row.html", {"book": book, "form": form})
@require_http_methods(["DELETE"])
def book_delete(request, pk):
get_object_or_404(Book, pk=pk).delete()
return HttpResponse("") # пустой ответ: строка исчезнет из таблицы
Шаблон формы в строке templates/books/book_edit_row.html:
<tr id="book-{{ book.pk }}">
<td colspan="4">
<form hx-post="{% url 'book_edit' book.pk %}"
hx-target="closest tr" hx-swap="outerHTML">
{{ form.as_div }}
<button type="submit">Сохранить</button>
<button type="button" hx-get="{% url 'book_row' book.pk %}"
hx-target="closest tr" hx-swap="outerHTML">Отмена</button>
</form>
</td>
</tr>
Если данные неверны, view вернёт ту же форму с ошибками, и она останется на месте. «Отмена» запрашивает исходную строку из book_row. Удаление работает через hx-delete: hx-confirm показывает диалог подтверждения браузера, пустой ответ с кодом 200 заменяет строку «ничем».
Важно: фрагменты — такие же URL, как страницы, и их может вызвать кто угодно. Проверяйте права в каждом view фрагмента:
login_required, проверку владельца вget_object_or_404(Book, pk=pk, owner=request.user). Изменяющие действия делайте только черезhx-post,hx-patchилиhx-delete— на GET-запросы CSRF-защита не распространяется.
Отправка формы с валидацией
Форма добавления книги работает и без JavaScript (атрибуты method и action), и через HTMX. При ошибке view возвращает только партиал формы с сообщениями, при успехе — HttpResponseClientRedirect на список:
@require_http_methods(["GET", "POST"])
def book_create(request):
form = BookForm(request.POST or None)
if request.method == "POST" and form.is_valid():
form.save()
if request.htmx:
return HttpResponseClientRedirect(reverse("book_list"))
return redirect("book_list")
template = "books/book_form.html"
if request.htmx:
template += "#book-form"
return render(request, template, {"form": form})
{% extends "base.html" %}
{% block content %}
<h1>Новая книга</h1>
{% partialdef book-form inline %}
<form method="post" action="{% url 'book_create' %}"
hx-post="{% url 'book_create' %}"
hx-target="this" hx-swap="outerHTML"
hx-disabled-elt="find button">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">Сохранить</button>
</form>
{% endpartialdef %}
{% endblock %}
hx-target="this" и hx-swap="outerHTML" заменяют форму её же новой версией с ошибками. hx-disabled-elt="find button" блокирует кнопку до ответа сервера, чтобы двойной клик на медленном соединении не создал две записи. Валидация описана один раз — в BookForm, как в обычной форме Django, и дублировать её на JavaScript не нужно.
Если отправить пустое название и 2999 год, HTMX заменит форму версией с сообщениями «Обязательное поле.» и «Год издания не может быть в будущем.» — при LANGUAGE_CODE = "ru" стандартные ошибки Django выводятся на русском.
Важно: невалидную форму возвращайте с кодом 200. htmx 2 не вставляет ответы 4xx и 5xx, и форма с кодом 422 просто не появится. В htmx 4 ответы с ошибками вставляются, а поведение для отдельных кодов настраивается атрибутом
hx-status.
Проверка через тестовый клиент Django
Логика HTMX-приложения живёт в view, поэтому её проверяют обычными тестами. Тестовый клиент Django принимает аргумент headers, и HTMX-запрос имитируется одним заголовком:
# books/tests.py
from django.test import Client, TestCase
from .models import Book
HX = {"HX-Request": "true"}
class BookHtmxTests(TestCase):
@classmethod
def setUpTestData(cls):
Book.objects.bulk_create(
Book(title=f"Django {i:02}", author="Автор", year=2020)
for i in range(1, 46)
)
def test_full_page_and_fragment(self):
full = self.client.get("/books/")
part = self.client.get("/books/", headers=HX)
self.assertContains(full, "<html")
self.assertNotContains(part, "<html")
self.assertEqual(part.content.decode().count("<tr id="), 20)
self.assertIn("HX-Request", full["Vary"])
def test_infinite_scroll_keeps_query(self):
r = self.client.get("/books/", {"q": "django", "page": 2}, headers=HX)
self.assertContains(r, 'hx-get="/books/?q=django&page=3"')
def test_invalid_form_returns_200(self):
data = {"title": "", "author": "X", "year": 2999}
r = self.client.post("/books/new/", data, headers=HX)
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Год издания не может быть в будущем.")
def test_success_uses_client_redirect(self):
data = {"title": "Новая", "author": "X", "year": 2020}
r = self.client.post("/books/new/", data, headers=HX)
self.assertEqual(r["HX-Redirect"], "/books/")
def test_csrf_header(self):
client = Client(enforce_csrf_checks=True)
token = str(client.get("/books/").context["csrf_token"])
url = f"/books/{Book.objects.first().pk}/delete/"
self.assertEqual(client.delete(url, headers=HX).status_code, 403)
headers = {**HX, "X-CSRFToken": token}
self.assertEqual(client.delete(url, headers=headers).status_code, 200)
python manage.py test books
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.093s
OK
Destroying test database for alias 'default'...
Тесты подтверждают, что view отдаёт полную страницу и фрагмент из 20 строк, датчик прокрутки сохраняет поиск, ошибки формы приходят с кодом 200, а запрос без CSRF-заголовка получает 403. Опечатку в hx-target такие тесты не поймают — для ключевых сценариев добавьте пару браузерных тестов на Playwright или Selenium. Как организовать тесты удобнее, — в статье про pytest-django.
Частые ошибки
| Симптом | Причина | Решение |
|---|---|---|
| Кнопка ничего не делает, в Network ответ 403 | Нет CSRF-токена | hx-headers с X-CSRFToken на body (в htmx 4 — с :inherited) |
| Ошибки формы не появляются | View вернул 400 или 422 | Возвращайте невалидную форму с кодом 200 |
| Внутри блока появилась вся страница с шапкой | View отдал полный шаблон на HTMX-запрос | Ветка if request.htmx с партиалом |
| Внутри блока появилась страница входа | Сессия истекла, login_required сделал редирект | Middleware, превращающий такой редирект в HX-Redirect (ниже) |
| «Назад» показывает фрагмент без стилей | Нет заголовка Vary: HX-Request | Декоратор vary_on_headers("HX-Request") |
| В новом фрагменте не работают JS-виджеты | Скрипт инициализировал их только при загрузке страницы | Инициализация в htmx.onLoad(callback) |
| Результаты поиска «прыгают» | Ответы на старые запросы приходят позже новых | hx-sync="this:replace" |
Проблема с истекшей сессией проявляется только в продакшене, когда вкладка открыта всю ночь. Небольшой middleware переводит редирект на страницу входа в полный переход браузера:
# books/middleware.py
from django.conf import settings
from django.shortcuts import resolve_url
from django_htmx.http import HttpResponseClientRedirect
class HtmxLoginRedirectMiddleware:
"""Редирект на страницу входа для HTMX-запросов делаем полным переходом."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
login_url = resolve_url(settings.LOGIN_URL)
if (
request.htmx
and response.status_code == 302
and response.url.startswith(login_url)
):
return HttpResponseClientRedirect(response.url)
return response
Подключите его в MIDDLEWARE после django_htmx.middleware.HtmxMiddleware. HTMX-запрос к закрытому view получит код 200 и заголовок HX-Redirect: /accounts/login/?next=/secret/, обычный запрос — прежний редирект 302.
Когда HTMX не подходит
HTMX закрывает большинство задач сайтов с серверной отрисовкой: каталоги, личные кабинеты, внутренние панели, CRUD-интерфейсы, формы с проверкой на сервере. Ограничение одно и принципиальное: каждое действие — это запрос к серверу.
| Критерий | HTMX | Alpine.js | SPA (React, Vue) + API |
|---|---|---|---|
| Где рендерится HTML | На сервере, в шаблонах Django | В браузере, поверх серверного HTML | В браузере |
| Что отдаёт Django | HTML-фрагменты | Страницы, иногда JSON | JSON, обычно через DRF |
| Реакция на действие | После ответа сервера | Мгновенная, без запроса | Мгновенная, данные подгружаются отдельно |
| Работа без сети | Нет | Только локальный UI | Возможна (PWA, offline-first) |
| Сборка фронтенда | Не нужна | Не нужна | Нужна |
| Типичные задачи | Поиск, фильтры, формы, подгрузка | Меню, вкладки, модальные окна | Редакторы, совместная работа, сложные дашборды |
Выбирайте SPA, если интерфейс сложнее набора страниц: графический или текстовый редактор, совместное редактирование в реальном времени, офлайн-режим, много состояния в браузере. Отдельный API на Django REST Framework понадобится и тогда, когда тот же бэкенд обслуживает мобильное приложение.
Alpine.js хорошо сочетается с HTMX: HTMX отвечает за общение с сервером, Alpine.js — за чисто клиентские мелочи вроде раскрывающегося меню, которым не нужен запрос. Для обновлений в реальном времени у HTMX есть расширения для Server-Sent Events и WebSocket, а на стороне Django — Django Channels.
Частые вопросы
Обязательно ли устанавливать django-htmx?
Нет. HTMX работает с любым бэкендом: достаточно подключить скрипт и проверять заголовок request.headers.get("HX-Request"). Пакет экономит код: даёт request.htmx с разобранными заголовками, готовые классы ответов вроде HttpResponseClientRedirect, тег htmx_script со встроенной htmx и отладочный скрипт для режима DEBUG.
Как показать индикатор загрузки?
Добавьте элемент с классом htmx-indicator и укажите его в hx-indicator. На время запроса HTMX делает индикатор видимым, после ответа снова скрывает:
<input name="q" hx-get="{% url 'book_list' %}" hx-target="#book-rows"
hx-trigger="input changed delay:300ms" hx-indicator="#spinner">
<span id="spinner" class="htmx-indicator">Ищем…</span>
Как обновить несколько частей страницы одним ответом?
Добавьте в ответ элементы с атрибутом hx-swap-oob="true" и тем же id, что на странице. HTMX вставит основной фрагмент в hx-target, а такие элементы — на их места. Например, вместе с новой строкой таблицы view может вернуть <span id="book-count" hx-swap-oob="true">47</span>, и счётчик в шапке обновится. Если зависимых блоков больше двух-трёх, удобнее отправить событие через trigger_client_event и дать каждому блоку обновиться самостоятельно.
Как передать в запрос дополнительные данные?
hx-vals добавляет значения в формате JSON, hx-include — поля других элементов по CSS-селектору:
<button hx-post="{% url 'book_publish' book.pk %}"
hx-vals='{"notify": "1"}'
hx-include="[name='comment']">Опубликовать</button>
Во view значения придут в request.POST (для hx-get — в request.GET), как поля обычной формы.
Как перейти на htmx 4?
Сначала прогоните проверку шаблонов: npx htmx.org@4.0.0 upgrade-check -- ./templates. Утилита найдёт атрибуты, которым нужен суффикс :inherited (в первую очередь hx-headers с CSRF-токеном), переименованные атрибуты и старые имена событий. Затем в django-htmx замените {% htmx_script %} на {% htmx_script version=4 %}. На время миграции можно подключить расширение htmx-2-compat: {% htmx_script version=4 extensions="htmx-2-compat" %}. Учтите, что в django-htmx 1.29 встроена бета-версия htmx 4 (4.0.0-beta6), а для финальной 4.0.0 нужен CDN или собственный файл в статике.
Итог
- HTMX добавляет в Django интерактивность атрибутами:
hx-getиhx-postотправляют запрос,hx-targetиhx-swapопределяют, куда и как вставить ответ,hx-trigger— когда отправлять. - Подключайте конкретную версию: с CDN, из статики или тегом
htmx_scriptиз django-htmx. Стабильная линия — htmx 2.0.11, htmx 4.0 вышел и помечен какnext. - Для POST и DELETE передавайте CSRF-токен через
hx-headersнаbody; в htmx 4 — с суффиксом:inherited. - Один view отдаёт полную страницу или фрагмент по
request.htmx. Фрагменты удобно объявлять партиалами Django 6.0 и рендерить по имени"шаблон.html#партиал"; добавляйтеVary: HX-Request. - Невалидную форму возвращайте с кодом 200, для перехода после успешного действия используйте
HttpResponseClientRedirect. - HTMX подходит для сайтов и панелей с серверной логикой. Для офлайн-режима, редакторов и сложного состояния в браузере нужен SPA с API.
Если вы только знакомитесь с фреймворком, начните с обзора Django для начинающих, а затем добавляйте HTMX к готовым страницам.
- #Django
- #HTMX
- #django-htmx
- #Шаблоны
- #Frontend