The htmx Pattern That Deletes Itself in Django

Last night I made the mistake of reading Alex Edwards’ “How I use HTMX with Go” at the exact hour a sensible person goes to sleep. If you write Go and touch htmx, go read it. It’s the kind of post that’s so tidy it feels rude, every piece clicks into the next one, and by the end you have a single render() helper that spits out either a full page or a bare HTML fragment depending on who’s asking.

I don’t write much Go. I write Django, and have for something like fifteen years. So my brain did the thing it always does with a good pattern from another ecosystem: “cute, but I want that in my house.” I opened a fresh project and started porting.

What follows is not really a tutorial about htmx. It’s a story about sitting down to rebuild something and discovering, one deleted file at a time, that the framework had already built it while I wasn’t looking.

The Post That Nerd-Sniped Me

Here’s the shape of Edwards’ pattern, so we’re on the same page.

He keeps three tiers of templates: a base layout, per-page content, and reusable partials. Then he writes a little htmlRenderer type in Go that parses a shared set of templates once at startup, and exposes a render() method that clones the set, buffers the output, and executes whichever named template you point it at. The clever bit is that “whichever named template” can be the whole page or just the table rows inside it. Same helper, same templates, one branch on an HX-Request header decides which chunk of HTML flies back to the browser.

It’s about fifty lines of Go across two files. Clean fifty lines. I wanted them in Python by breakfast.

I Came to Port, I Stayed to Delete

So I opened renderer.py and started translating his html.go into a Python class, the way you’d pack a moving truck: carefully, item by item, wrapping each one in bubble wrap. class HtmlRenderer. A method to load the shared templates. A method to clone them, render into a buffer, write the response.

I got maybe six lines in before I stopped, stared at the screen, and felt deeply, personally stupid.

Because Django’s render() already does all of that. The template loader already parses and caches the shared set. Template inheritance already handles base-plus-page. Rendering already buffers to a string before it touches the response. The entire htmlRenderer class I was so diligently reconstructing was a Go workaround for Go’s beautifully low-level, bring-your-own-everything html/template package. In Django that class isn’t a helper. It’s furniture that ships with the apartment.

I deleted the file. That felt good, so I went looking for the next thing to delete, and this is roughly how the rest of the night went. Every single component Edwards hand-rolls in Go, I set out to build, and every single time Django (or a tiny package sitting one pip install away) had already done it, silently, like a butler who’d finished the dishes while I was still rolling up my sleeves.

Let me show you the actual thing, and I’ll point out the corpses along the way.

The Two Packages Doing My Homework

You need two, and neither is exotic:

1pip install django-htmx django-template-partials whitenoise
  • django-htmx (Adam Johnson) gives every request a parsed request.htmx object. This is Edwards’ hand-written isHTMXRequest(), his browserURL(), and his HX-Redirect helper, all pre-assembled.
  • django-template-partials (Carlton Gibson) is the one genuinely load-bearing dependency, and I’ll explain why in a second.
  • whitenoise just so we serve htmx.min.js as a local static file instead of a CDN, which is Edwards’ “download a copy and serve it yourself” instinct and a good one.

Wire the first two into settings:

 1INSTALLED_APPS = [
 2    # ...
 3    "django_htmx",
 4    "template_partials",
 5]
 6
 7MIDDLEWARE = [
 8    # ...
 9    "django_htmx.middleware.HtmxMiddleware",
10]

That’s the whole install. We downloaded more bytes of blog post than plumbing.

Templates: The One Genuinely Missing Piece

Django’s template inheritance is great until you ask it the one question htmx makes you ask constantly: “render me just the <tbody>, not the page it lives in.” You can’t natively pluck a single block out of a template that {% extends %} a base and render it alone. This is the one place where Go’s approach was actually ahead, because Go’s named templates are addressable individually and Django’s blocks aren’t.

django-template-partials closes that exact gap. It’s basically a scalpel: it lets you slice one named fragment out of a template and render it without disturbing the rest of the patient.

Here’s base.html, which is the boring layout and does nothing surprising. Note the {% load static %} on the first line: {% load %} doesn’t propagate from a child template up into the base, so the layout has to load the tags it uses itself, and forgetting it throws Invalid block tag: 'static' the first time you hit the page:

 1{% load static %}
 2<!doctype html>
 3<html lang="en">
 4<head>
 5  <meta charset="utf-8">
 6  <title>{% block title %}{% endblock %}</title>
 7  <meta name="viewport" content="width=device-width, initial-scale=1">
 8
 9  <meta name="htmx-config" content='{
10    "includeIndicatorStyles": false,
11    "historyCacheSize": 0,
12    "historyRestoreAsHxRequest": false,
13    "disableInheritance": true,
14    "responseHandling": [
15      {"code": "204", "swap": false},
16      {"code": "422", "swap": true},
17      {"code": "[45]..", "swap": true, "target": "body"},
18      {"code": "...", "swap": true}
19    ],
20    "timeout": 5000
21  }'>
22
23  <link rel="stylesheet" href="{% static 'css/bamboo.min.css' %}">
24  <script defer src="{% static 'js/htmx.min.js' %}"></script>
25</head>
26<body>
27  <h1><a href="/">Example website</a></h1>
28  <main>{% block content %}{% endblock %}</main>
29</body>
30</html>

Now pages/users.html, and watch the {% partialdef %} block. That inline keyword means “define a fragment named user-rows and render it right here in place.” It’s the Django echo of Edwards putting a {{define "users:rows"}} right next to the markup that uses it, which is exactly the placement he argues for: a fragment used on only one page lives on that page, not exiled to some shared partials folder.

 1{% extends "base.html" %}
 2{% load partials %}
 3
 4{% block title %}Users{% endblock %}
 5
 6{% block content %}
 7  <input type="search" name="query"
 8    placeholder="Begin typing to search users..."
 9    hx-get="{% url 'users_search' %}"
10    hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
11    hx-target="#search-results"
12    hx-push-url="true">
13
14  <table>
15    <thead>
16      <tr><th>Name</th><th>Email</th><th>&nbsp;</th></tr>
17    </thead>
18    <tbody id="search-results">
19      {% partialdef user-rows inline %}
20        {% for u in users %}
21          <tr>
22            <td>{{ u.name }}</td>
23            <td>{{ u.email }}</td>
24            {# yes I kept his "gopher" field name, out of respect, #}
25            {# even though there is not a single gopher in Python, only mild betrayal #}
26            <td>{% if u.is_gopher %}🐍{% endif %}</td>
27          </tr>
28        {% endfor %}
29      {% endpartialdef %}
30    </tbody>
31  </table>
32{% endblock %}

The magic address is pages/users.html#user-rows. Hand Django that string and it renders only the fragment, no base, no page chrome, just the rows. Hold that thought for exactly one section.

The Whole Method, In One Boring View

This is the part where Edwards’ pattern either survives the port or doesn’t, and it survives so easily it’s almost anticlimactic. The full-page-versus-fragment decision, the thing his entire htmlRenderer exists to enable, comes down to choosing a string:

 1from django.shortcuts import render
 2
 3USERS = [
 4    {"name": "Alice Madsen",    "email": "alice@example.com",   "is_gopher": True},
 5    {"name": "Theo Thatcher",   "email": "theo@example.com",    "is_gopher": True},
 6    {"name": "Maxwell Albright","email": "maxwell@example.com", "is_gopher": False},
 7    {"name": "Ruby Thompson",   "email": "ruby@example.com",    "is_gopher": False},
 8    {"name": "Leona Rowan",     "email": "leona@example.com",   "is_gopher": False},
 9    {"name": "Leo Reynolds",    "email": "leo@example.com",     "is_gopher": False},
10]
11
12
13def users(request):
14    return render(request, "pages/users.html", {"users": USERS})
15
16
17def users_search(request):
18    query = request.GET.get("query", "").strip().lower()
19
20    if query:
21        matches = [
22            u for u in USERS
23            if query in u["name"].lower() or query in u["email"].lower()
24        ]
25    else:
26        matches = USERS
27
28    # Full page for a normal visitor, just the rows for htmx.
29    # This one line is the whole trick.
30    template = "pages/users.html#user-rows" if request.htmx else "pages/users.html"
31    return render(request, template, {"users": matches})
1# urls.py
2from django.urls import path
3from . import views
4
5urlpatterns = [
6    path("users/", views.users, name="users"),
7    path("users/search/", views.users_search, name="users_search"),
8]

That’s it. That’s the ballgame. request.htmx is truthy when the HX-Request: true header is present, so a real human who pastes /users/search?query=leo into their address bar gets a proper full page, and htmx typing into the box gets the bare <tbody> rows to swap in. Same view, same template file, same context dict. One ternary.

Edwards writes a struct, an initializer, a clone-and-buffer render method, and a isHTMXRequest helper to reach this exact behavior, and I want to be clear that this is not a knock on him. That’s the correct amount of code for Go. It’s just that Django handed me the same outcome for the price of one if.

The Plumbing Django Was Already Carrying

The rest of Edwards’ post is the honest, unglamorous stuff nobody puts in the demo GIF, and this is where the deletion spree really got going.

The Vary header. Because the same URL now returns different HTML depending on HX-Request, you have to tell caches that, or someone eventually gets a naked <tbody> served out of a CDN like a cruel prank. Edwards sets Vary: HX-Request on every response. This is the one bit of glue I actually had to write in Django, and it’s a six-line middleware:

 1from django.utils.cache import patch_vary_headers
 2
 3
 4class VaryHXMiddleware:
 5    def __init__(self, get_response):
 6        self.get_response = get_response
 7
 8    def __call__(self, request):
 9        response = self.get_response(request)
10        patch_vary_headers(response, ["HX-Request"])
11        return response

Redirects. Browsers swallow 3xx responses before htmx ever sees them, so the trick is to answer an htmx request with a 2xx plus an HX-Redirect header, and fall back to a normal redirect for everyone else (so the thing still works with JavaScript switched off). Edwards writes a redirect() helper for this. django-htmx ships the response class, so my “helper” is basically an import with a bow on it:

1from django.shortcuts import redirect
2from django_htmx.http import HttpResponseClientRedirect
3
4
5def htmx_redirect(request, url):
6    if request.htmx:
7        return HttpResponseClientRedirect(url)  # sends HX-Redirect, full reload
8    return redirect(url)

The current browser URL. Sometimes the server wants to know what URL the user is actually looking at, which htmx sends in HX-Current-URL. Edwards parses that header into a helper. Mine reads request.htmx.current_url. That was already deleted before I wrote it.

Errors. By default htmx refuses to swap in 4xx and 5xx responses, so your error message dies quietly in the console where nobody sees it. Both of us solve this identically, because the fix is client-side and ports with zero changes: the responseHandling block in that config meta tag up in base.html. It says 4xx/5xx should swap into <body> as a full-page error, 422 swaps into the target as normal (that’s your form-with-validation-errors case), and 204 does nothing. Copy Edwards’ JSON, paste it into a Django <meta> tag, done. It doesn’t know or care what language rendered the page around it.

The Deletion Tally

Let me count the bodies. Here is everything Edwards writes by hand in Go, and what it cost me in Django:

  • htmlRenderer struct + newHTMLRenderer + render() → deleted, it’s django.shortcuts.render
  • isHTMXRequest() → deleted, it’s request.htmx
  • browserURL() header parsing → deleted, it’s request.htmx.current_url
  • The HX-Redirect mechanics → deleted, it’s HttpResponseClientRedirect
  • Template embedding via //go:embed → deleted, it’s the staticfiles system plus whitenoise
  • The Vary header → survived, six lines of middleware
  • The htmx config <meta> tag → survived, copied over byte for byte

Two things survived. Everything else was already in the box. I set out to port a pattern and ended up mostly writing a changelog of things I didn’t have to do.

And the honest footnote: this isn’t Django being clever and Go being dumb. It’s the two languages sitting at opposite ends of a dial. Go’s standard library hands you sharp, minimal primitives and trusts you to assemble the machine, which is genuinely lovely if that’s the kind of control you want. Django hands you the assembled machine and hides the screws. Edwards’ post is beautiful precisely because Go makes him show his work. Porting it to Django just means most of the work was already homework somebody else turned in years ago.

In Praise of the Framework That Already Did It

There’s a particular flavor of programmer joy that nobody advertises, and it’s not building something. It’s opening your editor fully intending to build something, and realizing halfway through that you don’t have to, because the boring, unfashionable, fifteen-year-old framework under your fingers quietly solved this before you showed up. Deleting the file you came to write is a better feeling than shipping it.

So the fastest way I know to use htmx with Django is this: read a great post about using it with Go, get excited, sit down to copy it, and then keep hitting Backspace until only the good parts are left. There were two good parts. That was the whole exercise.

Go read Edwards’ original, seriously, it’s better than this one and it taught me the pattern. I just had less of it to build.

A note on how this got written, because my last post had one too: I drafted this alongside an LLM, then went through by hand tearing out the parts that sounded like a press release and putting my own dumb jokes back in. The bad jokes are mine. Blame the human.

Update, later that same night: I did actually finish the port, and the finished project is smaller than the blog post describing it. I’m choosing to find that funny instead of concerning.