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

{% partialdef name %}…​{% endpartialdef %} defines it, {% partial name %} renders it, as often as needed.

Defining and rendering partials

Render the piece where it is defined

Add the inline option to partialdef.

The inline option

Render only the piece from a view

render(request, "page.html#name", context); the same # syntax works with get_template() and {% include %}.

Rendering one partial on its own: template#partial

Return a fragment only for htmx requests

Add HtmxMiddleware, test request.htmx, append #name to the template name, and send Vary: HX-Request.

One view, two renderings: a searchable table

Update one row after a click

hx-post on the button, hx-target on the row, hx-swap="outerHTML", and render the row partial.

Swapping a single row: a favourite button

Validate a form without a page reload

Re-render the form partial with status 422 and tell htmx to swap 422 responses.

A form that validates in place

Redirect or fire events from the server

HttpResponseClientRedirect and trigger_client_event from django_htmx.http.

The django-htmx response helpers

Keep CSRF protection on

Send the token in an x-csrftoken header with hx-headers on the <body>.

CSRF with htmx

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.

When to choose this instead of a single-page application

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.

Defining a partial once and rendering it twice, with a different title the first time
{% 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.

Defined only, versus defined and rendered in place
{% 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.

Selecting a partial by name from Python
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})
Including a partial of another template
{% 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

hx-get, hx-post, hx-put, hx-patch, hx-delete

Issue a request with that method to the given URL when the element is triggered.

hx-trigger

Which event triggers it. The default is click for most elements, change for inputs, submit for forms; modifiers such as changed and delay:300ms debounce a search box.

hx-target

The element that receives the response, as a CSS selector (#book-table, this). Defaults to the element that issued the request.

hx-swap

How the response is placed: innerHTML (default), outerHTML, beforeend and others.

hx-push-url

Push the request URL into browser history, so the back button and bookmarks keep working.

hx-boost

Turn ordinary links and forms into AJAX requests that swap the page body, degrading to normal navigation without JavaScript.

hx-headers

Extra request headers; used here to carry the CSRF token.

hx-swap-oob

In a response, mark an element to be swapped into the element with the same id elsewhere in the page ("out of band").

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.

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

Wiring django-htmx into the settings
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.

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.

catalog/templates/catalog/book_list.html
{% 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>
catalog/views.py
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})
catalog/urls.py
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:

The same URL as a normal request and as an htmx request
$ 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.

The test that pins the behaviour
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.

catalog/views.py (continued)
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>.

Sending the CSRF token with every htmx request
<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 %}.

catalog/templates/catalog/_base.html
{% 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>
catalog/templates/catalog/book_new.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 %}
catalog/forms.py
from django import forms

from .models import Book


class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "author"]
catalog/views_form.py
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:

Tests for the form flow
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.

Choosing a different swap and target from the server
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.

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.

Loading htmx with the nonce and a non-default version
{% 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.

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_404 that is scoped to the current user, exactly like the page.

  • State changes through GET. htmx makes it easy to put hx-get on a delete link; use hx-post or hx-delete with CSRF.

  • Swapping targets that do not exist or have no id. The most frequent cause of "nothing happens" is a hx-target selector that matches nothing.

  • 4xx responses silently ignored. htmx 2 does not swap 4xx and 5xx responses; configure responseHandling for 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 href and forms a real action, 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 action and href (progressive enhancement).

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: {% partialdef %}, {% partial %}, the inline option and template#partial with get_template(), render(), {% include %} and other template-loading tools. Also the CSP support that django-htmx’s script tag integrates with.

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 as_div, the template-based form rendering available since 4.1.

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_script %} adds the CSP nonce on Django 6.0+.

htmx 4

In beta at the time of writing; there is no htmx 3. HtmxDetails attributes differ between htmx 2 and 4 (trigger and trigger_name for 2, request_type and source for 4), and the CSRF header recipe uses hx-headers:inherited on the body for htmx 4.