Template Partials & htmx
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and
django-htmx 1.29.0 (2026-08-05, which vendors htmx 2.0.10 as its default and htmx 4.0.0-beta6 as an opt-in), all checked on
2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on
Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 15 tests (manage.py test) and a curl session
against runserver all passed, and the output shown is the real output. Nothing on this page needs PostgreSQL or Redis. The
browser side of htmx (the JavaScript that reads the hx-* attributes) was not driven by a real browser: what was verified is
what the server returns for the requests htmx sends, which is the part Django is responsible for. Django 6.0 added template
partials to the template language: named fragments that live inside a template file and can be rendered in place, reused, or
rendered alone by a view. That last ability is what makes the htmx style of web development pleasant in Django. The server
stays the owner of the HTML, the page works without JavaScript, and a click, a keystroke or a form submission replaces one
fragment of the page by asking the server for it. The running scenario is the Bookshelf application (app name catalog): a
searchable book table whose rows have a favourite button, and a "new book" form with server-side validation.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Name a reusable piece of a template |
|
|
Render the piece where it is defined |
Add the |
|
Render only the piece from a view |
|
|
Return a fragment only for htmx requests |
Add |
|
Update one row after a click |
|
|
Validate a form without a page reload |
Re-render the form partial with status 422 and tell htmx to swap 422 responses. |
|
Redirect or fire events from the server |
|
|
Keep CSRF protection on |
Send the token in an |
|
Decide between this and a SPA |
Server-rendered pages with islands of interactivity, unless the product is an application with rich client-side state or offline needs. |
The problem partials solve
A server-rendered page is a tree of HTML that the server produces from a template. When a small part of the page has to change
after the page has loaded (a search box narrows a table, a button toggles a flag, a form shows its errors) there are three ways
to do it. Reload the whole page, which is simple but throws away scroll position, focus and typed text. Send JSON and write
JavaScript that rebuilds the HTML, which duplicates the template on the client. Or ask the server for just the changed
fragment and swap it into the page, which is what htmx automates. The third way needs the server to be able to render a piece
of a template. Before Django 6.0 that meant splitting every fragment into its own small file and including it from the page
with {% include %}, or installing a third-party package. Two files per feature, one for the page and one for each fragment,
made the page harder to read because its pieces were scattered across the project.
Template partials keep the page and its fragments in one file. The fragment is defined once, rendered inside the page for a normal request, and rendered on its own for an htmx request, with the same context and the same auto-escaping.
Defining and rendering partials
{% partialdef name %} opens a partial and {% endpartialdef %} closes it. The name is required and must be a valid template
identifier; hyphens are fine (book-table). The closing tag may repeat the name ({% endpartialdef book-table %}), which
helps when partials are long or nested. {% partial name %} renders the partial at that point. The partial is rendered with
the current context each time it is used, so it works inside {% for %} loops and under {% with %}, and its output is
auto-escaped like any other template output. A partial is not a function with arguments: it sees whichever variables are in the
context where it is used, and {% with %} is how you change them for one use.
{% partialdef card %}<p>{{ title }}</p>{% endpartialdef %}
{% with title=x %}{% partial card %}{% endwith %}
{% partial card %}
Rendered with the context {"x": "<b>", "title": "plain"} this produced <p><b></p><p>plain</p>: escaping is applied
inside the partial and {% with %} changed the variable only for the first use. A partial can even be used before the place
where it is defined ({% partial card %}{% partialdef card %}hi{% endpartialdef %} rendered hi), because the definitions are
collected when the template is compiled, but defining first reads better.
The inline option
By default a partialdef only defines the fragment; nothing is output where it is written. The inline option defines it
and renders it at the same time, so the fragment appears in the normal page and is also available by name. This is the form
htmx pages use most: the table is part of the page, and the same table is what a search request returns.
{% partialdef a %}A{% endpartialdef %}|{% partialdef b inline %}B{% endpartialdef %}
The output is |B: a was defined but not rendered, b was defined and rendered.
Rendering one partial on its own: template#partial
Appending #name to a template name selects one partial of that template. It works wherever Django accepts a template name:
the render() shortcut, get_template(), render_to_string() and {% include %}. Only the partial is rendered, but the
template’s other partials are available to it, so a partial can use its siblings.
from django.template.loader import get_template, render_to_string
fragment = get_template("catalog/book_list.html#row")
html = fragment.render({"book": book})
html = render_to_string("catalog/book_list.html#row", {"book": book})
{% include "catalog/book_list.html#row" %}
An unknown partial name raises TemplateDoesNotExist, the same exception as for a missing file, so a typo is caught by the
first test that renders it (the scratch suite asserts exactly that). Because the whole template is compiled and only a part is
rendered, anything outside the chosen partial costs nothing at render time: in the scratch project a {% for %} over a
queryset outside the partial ran one query when the whole template was rendered and none when #p was rendered. A lazy
queryset that the fragment does not use is therefore never evaluated.
The # syntax belongs to Django’s own template engine and to its template loaders, so this page’s examples target the Django
Template Language. The Jinja2 backend (see Templates) is a separate engine and the partial
tags are documented for the Django template language only.
Where partials sit in the request/response cycle
Nothing about a partial changes the cycle described in the request/response cycle:
the URL resolver picks a view, middleware runs around it, and the view returns an HttpResponse. The only difference is the
name passed to render(). A partial request still passes through every middleware (sessions, authentication, CSRF), so a
fragment is exactly as protected as the page, but also exactly as unprotected: a fragment URL is a normal URL that anybody
who can guess it can request, so permission checks belong in the view (or a decorator), never in the assumption that "only the
page links here". Template rendering is synchronous in both sync and async views, and in an async view the ORM must not be
touched from inside the template; evaluate the queryset in the view before rendering. django_htmx.middleware.HtmxMiddleware
supports sync and async modes.
htmx in one page
htmx is a small JavaScript library that lets any HTML element issue an HTTP request and swap the HTML in the response into the page, using attributes instead of JavaScript. The page is the hypermedia: the server answers with HTML, not JSON. The attributes that matter for a Django developer are these.
| Attribute | Meaning |
|---|---|
|
Issue a request with that method to the given URL when the element is triggered. |
|
Which event triggers it. The default is |
|
The element that receives the response, as a CSS selector ( |
|
How the response is placed: |
|
Push the request URL into browser history, so the back button and bookmarks keep working. |
|
Turn ordinary links and forms into AJAX requests that swap the page body, degrading to normal navigation without JavaScript. |
|
Extra request headers; used here to carry the CSRF token. |
|
In a response, mark an element to be swapped into the element with the same |
Every request htmx makes carries an HX-Request: true header, which is how the server tells an htmx request from a normal
navigation. htmx 2 swaps responses with 2xx and 3xx status codes, ignores 204, and by default does not swap 4xx and 5xx
responses; that behaviour is configurable through htmx.config.responseHandling and matters for form validation below.
Source: htmx documentation
django-htmx
The django-htmx package (Adam Johnson) is the Django integration. It supports Django 5.2 to 6.1 and Python 3.11 to 3.15 in
version 1.29.0. It provides four things: a middleware that adds request.htmx, a {% htmx_script %} template tag that
renders a <script> tag for a vendored copy of htmx (and, with DEBUG on, a script that shows server errors in the browser),
response classes and helpers for htmx’s response headers, and tips such as the CSRF recipe below. Installing is two settings
entries and, for the tag, a {% load django_htmx %}.
INSTALLED_APPS = [
"django.contrib.staticfiles",
"django_htmx",
"catalog",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
request.htmx is a HtmxDetails object. Its truth value is true when the request carries HX-Request: true, so
if request.htmx: is the usual switch; request.htmx.boosted says the request came from an hx-boost element,
request.htmx.target is the target element, request.htmx.current_url is the browser URL the request was made from, and
request.htmx.prompt carries an hx-prompt answer. trigger and trigger_name exist for htmx 2 only, while request_type
and source exist for htmx 4 only.
Source: django-htmx: Installation and django-htmx: Middleware
One view, two renderings: a searchable table
The central pattern is a single view that serves the full page to a normal request and just the table to an htmx request. The
template defines the table as an inline partial, so a normal request renders it inside the page and an htmx request selects it
with #book-table.
{% load django_htmx %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalog</title>
{% htmx_script %}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
<h1>Books</h1>
<input type="search" name="q" value="{{ q }}" placeholder="Search"
hx-get="{% url 'book-list' %}" hx-trigger="input changed delay:300ms"
hx-target="#book-table" hx-swap="outerHTML" hx-push-url="true">
{% partialdef row %}
<tr id="book-{{ book.pk }}">
<td>{{ book.title }} by {{ book.author }}</td>
<td>
<button hx-post="{% url 'book-favourite' book.pk %}"
hx-target="#book-{{ book.pk }}" hx-swap="outerHTML">
{% if book.favourite %}Unfavourite{% else %}Favourite{% endif %}
</button>
</td>
</tr>
{% endpartialdef row %}
{% partialdef book-table inline %}
<table id="book-table">
{% for book in books %}
{% partial row %}
{% endfor %}
</table>
{% endpartialdef book-table %}
</body>
</html>
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_GET, require_POST
from django.views.decorators.vary import vary_on_headers
from .models import Book
@require_GET
@vary_on_headers("HX-Request")
def book_list(request):
q = request.GET.get("q", "")
books = Book.objects.filter(title__icontains=q)
template_name = "catalog/book_list.html"
if request.htmx:
template_name += "#book-table"
return render(request, template_name, {"books": books, "q": q})
from django.urls import path
from . import views
urlpatterns = [
path("books/", views.book_list, name="book-list"),
path("books/<int:pk>/favourite/", views.book_favourite, name="book-favourite"),
]
The search box sends GET /books/?q=… 300 milliseconds after the user stops typing and only if the value changed; the
response replaces the table (hx-target="#book-table" with outerHTML, so the response must itself be a <table
id="book-table">, which is why the partial includes the <table> tag), and hx-push-url makes the filtered URL bookmarkable.
The row partial is used by the table and, as the next section shows, by a second view. A curl session against runserver
shows both renderings of the same URL:
$ curl -s -i localhost:8130/books/ | sed -n 1,6p
HTTP/1.1 200 OK
Date: Sat, 03 Oct 2026 00:03:15 GMT
Server: WSGIServer/0.2 CPython/3.13.16
Content-Type: text/html; charset=utf-8
Vary: HX-Request, Cookie
$ curl -s -i -H 'HX-Request: true' 'localhost:8130/books/?q=dun'
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: HX-Request
Content-Length: 274
<table id="book-table">
<tr id="book-1">
<td>Dune by Frank Herbert</td>
...
</table>
The first response is the whole document (1093 bytes, with the <h1>, the search box and both books); the second contains only
the filtered table (274 bytes). Both responses carry Vary: HX-Request, which is not optional. One URL now has two possible
bodies, so a browser cache, a CDN or Django’s cache middleware must key the cached copy on the HX-Request header, or the
fragment can be served to a browser that asked for a page, and the other way round. Add the header with vary_on_headers
(shown above) or patch_vary_headers; the cache middleware and its ordering are covered in Middleware. The same caution applies to htmx’s history: when a user navigates back to a page that
htmx pushed into history and the browser cache has missed, htmx re-requests the URL expecting a complete page, so a pushed URL
must always be able to return the whole page; htmx documents htmx.config.historyRestoreAsHxRequest for this case, and the
django-htmx middleware exposes request.htmx.history_restore_request.
from django.test import TestCase
from django.urls import reverse
from .models import Book
class PartialTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.dune = Book.objects.create(title="Dune", author="Frank Herbert")
cls.emma = Book.objects.create(title="Emma", author="Jane Austen")
def test_full_page_for_plain_request(self):
response = self.client.get(reverse("book-list"))
self.assertContains(response, "<h1>Books</h1>", html=True)
self.assertContains(response, 'id="book-table"')
def test_fragment_for_htmx_request(self):
response = self.client.get(
reverse("book-list"), {"q": "dun"}, headers={"HX-Request": "true"}
)
self.assertNotContains(response, "<h1>")
self.assertContains(response, "Dune")
self.assertNotContains(response, "Emma")
self.assertEqual(response.headers["Vary"], "HX-Request")
The test client sends an htmx request by setting the header itself; no JavaScript is needed to test the server side.
Swapping a single row: a favourite button
A button inside a table row needs the row back, not the whole table. The second view returns the row partial through
catalog/book_list.html#row, and the button replaces its own row with hx-target="#book-<pk>" and outerHTML.
from django.contrib.auth.decorators import login_required, permission_required
@login_required
@permission_required("catalog.change_book", raise_exception=True)
@require_POST
def book_favourite(request, pk):
book = get_object_or_404(Book, pk=pk)
book.favourite = not book.favourite
book.save(update_fields=["favourite"])
return render(request, "catalog/book_list.html#row", {"book": book})
The view follows the usual rules: only signed-in editors may change the flag (login_required redirects anonymous
visitors to the login page and permission_required answers 403 to users without catalog.change_book, because in this
example favourite is a catalogue-wide flag curated by staff; per-reader favourites would be a many-to-many relation to
the user, with the query scoped to request.user), state changes use POST (require_POST answers a GET from an
editor with 405), and CSRF protection stays on (a POST without a token answers 403 before the view runs). The 405 and
403 were verified with curl against the view without the two access decorators; with them, an anonymous request is
redirected to the login page first. Because the row is a partial of
the same template as the table, the markup of a row exists in exactly one place; change the row and both the page and the
swap change.
CSRF with htmx
Django’s CSRF middleware rejects an unsafe request without a valid token, and htmx’s hx-post is such a request. Django
accepts the token in the x-csrftoken header (CSRF_HEADER_NAME can change it, and there is rarely a reason to), and the
neat way to send it from every htmx request is hx-headers on the <body>, which every element inherits. The value must be
the variable {{ csrf_token }}, not the tag {% csrf_token %}, which renders a hidden <input>.
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
That attribute is already in the book_list.html listing above, and the row test proves it matters: the Django test client
disables CSRF checks by default, so the test builds a client with enforce_csrf_checks=True and asserts that a token-less
POST is answered with 403. Forms rendered inside a fragment can instead keep {% csrf_token %} in the form, as the next
section does; both mechanisms are the same protection (see Middleware for CsrfViewMiddleware).
A form that validates in place
The most valuable use of partials is a form: the page renders it normally, and when the submission is invalid the server
re-renders just the form with its errors and htmx swaps it back. The form is a partial of a template that extends a base, so
this also shows partials inside {% block %}.
{% load django_htmx %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Catalog{% endblock %}</title>
<meta name="htmx-config" content='{"responseHandling": [
{"code": "204", "swap": false},
{"code": "[23]..", "swap": true},
{"code": "422", "swap": true},
{"code": "[45]..", "swap": false, "error": true},
{"code": "...", "swap": true}]}'>
{% htmx_script %}
</head>
<body hx-headers='{"x-csrftoken": "{{ csrf_token }}"}'>
{% block content %}{% endblock %}
</body>
</html>
{% extends "catalog/_base.html" %}
{% block content %}
<h1>New book</h1>
{% partialdef book-form inline %}
<form id="book-form" method="post" action="{% url 'book-new' %}"
hx-post="{% url 'book-new' %}" hx-target="this" hx-swap="outerHTML">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">Save</button>
</form>
{% endpartialdef book-form %}
{% endblock %}
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "author"]
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django_htmx.http import HttpResponseClientRedirect, trigger_client_event
from .forms import BookForm
@require_http_methods(["GET", "POST"])
def book_new(request):
form = BookForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
book = form.save()
url = reverse("book-list")
if request.htmx:
response = HttpResponseClientRedirect(url)
return trigger_client_event(response, "book-saved", {"id": book.pk})
return HttpResponseRedirect(url)
template_name = "catalog/book_new.html"
if request.htmx:
template_name += "#book-form"
return render(request, template_name, {"form": form}, status=422)
return render(request, "catalog/book_new.html", {"form": form})
Four details make this work. The form has both a normal method/action and the htmx attributes, so without JavaScript it
is a plain form post (this is progressive enhancement: htmx is an improvement, not a requirement). An invalid submission
answers with status 422 and, for htmx, only the form partial; htmx 2 would not swap a 4xx response by default, so the
htmx-config meta tag adds 422 to the swapped codes, following the htmx documentation’s own example. A normal (non-htmx)
invalid post gets the full page, also with status 422. A valid htmx submission cannot just redirect(), because the browser
would follow the redirect inside the AJAX request and htmx would swap the whole destination page into the form; instead the
view answers with HttpResponseClientRedirect, which sends the HX-Redirect header and makes htmx navigate the browser, while
a normal submission gets a normal HttpResponseRedirect. These four behaviours are pinned by tests:
def test_invalid_htmx_post_returns_fragment_with_422(self):
response = self.client.post(
reverse("book-new"), {"title": ""}, headers={"HX-Request": "true"}
)
self.assertEqual(response.status_code, 422)
self.assertNotContains(response, "<html", status_code=422)
self.assertContains(response, "This field is required.", status_code=422)
def test_valid_htmx_post_redirects_client_side(self):
response = self.client.post(
reverse("book-new"),
{"title": "Emma", "author": "Jane Austen"},
headers={"HX-Request": "true"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["HX-Redirect"], reverse("book-list"))
self.assertIn("book-saved", response.headers["HX-Trigger"])
The model form lists its fields explicitly, as every form in this section does; see Models and Fields for the model, and the forms pages of this section for validation, widgets and rendering with as_div.
The django-htmx response helpers
htmx lets the server steer the browser through response headers, and django-htmx wraps them in classes and functions so that
no header name is typed by hand. The response classes are HttpResponseClientRedirect (HX-Redirect),
HttpResponseClientRefresh (HX-Refresh), HttpResponseLocation (HX-Location, a client-side boosted navigation with
optional target, swap and select) and HttpResponseStopPolling (status 286, which stops an hx-trigger="every …" poll).
The functions take a response and return it with a header set: push_url (HX-Push-Url), replace_url, reswap, retarget,
reselect, and trigger_client_event, which fires a browser event that other elements can listen for with
hx-trigger="book-saved from:body". The form view above used two of them.
from django_htmx.http import reswap, retarget
response = render(request, "catalog/book_new.html#book-form", {"form": form}, status=422)
response = retarget(response, "#form-errors")
response = reswap(response, "innerHTML")
Rendering the form partial with those two calls returned status 422 with the headers HX-Retarget: #form-errors and
HX-Reswap: innerHTML. These helpers keep the markup simple: the same fragment can be sent to different places depending on
what the server decided, without teaching the page about every outcome.
Source: django-htmx: HTTP tools
Loading htmx and Content Security Policy
{% htmx_script %} renders <script> tags with the defer attribute for the htmx copy that django-htmx vendors, so there is
no CDN request: serving scripts from your own origin is better for privacy, performance and security, and it is what the
django-htmx documentation recommends for htmx extensions too (download them into your static files instead of loading them from
a public CDN). Pass minified=False while debugging, version=4 to opt into htmx 4 (beta at the time of writing, and
there is no htmx 3: the project went from 2 to 4), and extensions="hx-sse,hx-ws" for vendored extensions named as in htmx 4.
With Django 6.0’s Content Security Policy support (covered in the security and CSP page of this section), the tag
adds the CSP nonce to the <script> tags when one is present in the template context, so the page can keep a strict
script-src without allowing inline scripts. Note that htmx itself uses eval for a few features (event filters, hx-on
attributes, hx-vals with the js: prefix); htmx.config.allowEval can switch them off if the policy forbids it.
{% load django_htmx %}
{% htmx_script version=4 extensions="hx-sse" %}
Rendering that tag with csp_nonce in the context produced <script src="/static/django_htmx/htmx-4.min.js" defer nonce="abc">,
the hx-sse extension script with the same nonce, and, because DEBUG was on, the django-htmx debug script.
Source: django-htmx: Template tags
Other ways to render only part of a page
Partials are the cleanest, but two older techniques still appear in codebases. Splitting each fragment into its own file and
using {% include %} works on any Django version and is perfectly fine for a fragment shared by many pages. Swapping the base
template (a context variable used in {% extends base_template %}, set to a minimal wrapper for htmx requests) renders the
whole page body but without the layout; it suits "boosted" navigation where the whole <main> changes. Each of these
shows up in the django-htmx tips. Neither makes a page easier to read than a partial does, but the base-swap technique remains
the right tool when the fragment is a whole page.
Pitfalls
-
Forgetting
Vary. A view that returns different bodies for the same URL must say so (Vary: HX-Request), otherwise caches mix them up. -
Fragment endpoints are real endpoints. They need authentication and permission checks, and a
get_object_or_404that is scoped to the current user, exactly like the page. -
State changes through
GET. htmx makes it easy to puthx-geton a delete link; usehx-postorhx-deletewith CSRF. -
Swapping targets that do not exist or have no
id. The most frequent cause of "nothing happens" is ahx-targetselector that matches nothing. -
4xx responses silently ignored. htmx 2 does not swap 4xx and 5xx responses; configure
responseHandlingfor 422, as in the form example. -
Fragment and page drift. Keep a fragment inside the page’s template (a partial) instead of copying markup, or the two will diverge.
-
Features that only work with JavaScript. Give links a real
hrefand forms a realaction, so that htmx enhances them instead of replacing them.
Testing
Fragments are easy to test because they are plain responses. Send the HX-Request header with the test client
(headers={"HX-Request": "true"}), assert on the markup with assertContains(…, html=True), assert the status and the
HX-* response headers, and add one test per variation (full page, fragment, invalid fragment). The scratch suite has 15 tests
for this page’s examples and ran in 0.015 seconds. Browser-level behaviour (the swap really happening, focus kept, history
updated) belongs in an end-to-end test; see end-to-end testing with real browsers.
Source: Testing tools
When to choose this instead of a single-page application
A single-page application (React, Vue or Angular, see React, Vue and Angular) moves rendering to the browser and turns the server into a JSON API. A Django site with partials and htmx keeps rendering on the server and adds interactivity by fragment. Neither is better in general; the choice is about what the product is.
| Concern | Server-rendered with partials and htmx | SPA with a JSON API |
|---|---|---|
Typical product |
Content sites, catalogues, back offices, dashboards, CRUD applications, internal tools. |
Editors, design tools, mail and chat clients, applications with rich client-side state or offline use. |
Code to maintain |
One codebase: Django templates, views and a few attributes. No client build step is required. |
Two codebases: a JSON API (DRF or Ninja) and a front end with its own build, routing and state management. |
First load and SEO |
Complete HTML from the first response; works for crawlers and slow devices. |
An empty shell until JavaScript runs, unless server-side rendering is added. |
Works without JavaScript |
Yes, when forms and links keep their normal |
No, as a rule. |
Authentication and permissions |
Django sessions and CSRF as usual, enforced in the view that renders the HTML. |
Tokens or cookies plus a second set of checks on every API endpoint; see CORS for cross-origin cases. |
Interaction ceiling |
Fragment swaps, polling, server-sent events and WebSockets (via extensions); drag-and-drop canvases and heavy client-side logic are awkward. |
Unlimited; the client owns the state. |
Accessibility |
Plain HTML semantics by default; swapped content needs focus and live-region care (Web accessibility). |
Entirely the front end’s responsibility. |
A pragmatic rule is to start with Django templates and htmx and to reach for a SPA when a specific screen needs client-side state that cannot be expressed as "ask the server for a new fragment". The two are not exclusive: a Django site can serve most pages as HTML and mount a JavaScript widget on one page, and the DRF and Django Ninja pages of this section show how the same models are served as JSON for the pages and apps that do need it. The htmx project’s own essays discuss when hypermedia is the right fit and where it is not.
Coming from django-template-partials, and Django 5.2 LTS
Before Django 6.0 the same idea was provided by the third-party django-template-partials package: you installed it,
listed it in INSTALLED_APPS, wrote {% load partials %} in each template and used the same partialdef / partial tags
and the template#partial name syntax. The Django 6.0 release notes link to a migration guide in the package’s repository.
The migration is small: remove the app from INSTALLED_APPS, delete the {% load partials %} lines (the tags are built in), and
uninstall the package. The latest release of the package at the time of writing is 25.3, whose classifiers stop at Django 6.0.
Django 5.2 LTS (supported until April 2028) has no built-in partials, so a project that stays on 5.2 should keep the package
and plan the removal for the move to 6.x. django-htmx 1.29.0 supports Django 5.2 to 6.1, so the htmx half of this page works
unchanged on 5.2, and only the {% partialdef %} / {% load partials %} difference needs attention.
Where the books are outdated
The four reference books this section was cross-checked against were written for Django 1.11–2.1, long before
template partials existed in Django 6.0, so they cannot describe the built-in tags; where they discuss reusable template
pieces the tool they have is {% include %} with one file per fragment. Two older idioms in this area should not be copied
into new code. Detecting AJAX with request.is_ajax() was removed in Django 4.0 (replace it with a header such as HX-Request,
and with request.htmx when django-htmx is installed), and the django-template-partials package is no longer needed from
Django 6.0 on. The books also predate htmx (first released in 2020) and the hypermedia approach described here.
What changed recently
| Version | Change |
|---|---|
Django 6.0 |
Template partials: |
Django 6.1 |
The 6.1 release notes contain no partials entry; 6.1.1 is the version used for every example on this page. The form example uses |
django-htmx 1.29.0 (2026-08-05) |
Supports Django 5.2 to 6.1; vendors htmx 2.0.10 by default and htmx 4.0.0-beta6 on request, with vendored extensions; |
htmx 4 |
In beta at the time of writing; there is no htmx 3. |
Source: Django 6.0 release notes and django-htmx documentation
References
-
Thymeleaf: htmx Fundamentals — the same htmx style of fragment swapping with Thymeleaf on Spring Boot