Progressive enhancement in Django with htmx

Progressive enhancement in Django with htmx Table of Contents

Welcome reader to one of those articles where it seems I'm stating the obvious! But frontend developers constantly forget the most obvious things to do, so here I am.

I've always been a proponent of progressive enhancement, and in general of keeping things simple. However, building things this way might not always be possible for a number of reasons.

When finally the stars align and you get the chance to truly adopt progressive enhancement, patterns start to emerge quickly, and things finally click (no pun intended)!

In Django, for example, adopting progressive enhancement with htmx might be easier than you think.

Let's see!

Progressive enhancement in Django with htmx: the template

Consider the following example. To start with you've got a link augmented with hx-get. The conscious developer should ask itself first of all: what happens if JavaScript doesn't work, or if it's disabled at all in the user browser? For example, this won't work when the user clicks the link:

{% url "book-detail" book.pk as book_detail %}
<a hx-get="{{ book_detail }}">{{ book.name }}</a>

This instead will work, providing progressive enhancement to our users:

{% url "book-detail" book.pk as book_detail %}
<a href="{{ book_detail }}" hx-get="{{ book_detail }}">{{ book.name }}</a>

Magic!

If the user clicks the link and JavaScript is disabled, the browser will happily follow the link, providing the native link experience for the user. If JavaScript is enabled instead, htmx will get into action, fetch the content from the url provided in hx-get, and swap the latter into the given target.

Let's see into the next section how to handle the Django view.

Progressive enhancement in Django with htmx: the view

To make the magic of progressive enhancement happen in Django we need to serve different templates, depending on whether the request comes from JavaScript (via htmx) or from a regular browser HTTP request.

Consider the following Django app structure:

books/templates
└── books
├── partials
│ └── book.html
├── books_detail.html

The arrangement here sees books_detail.html including the partial:

{# here you would extend from base.html #}

{% block content %}
<div>
{% include "books/partials/book.html" %}
</div>
{% endblock content %}

Here's instead the partial book.html (where object is a book):

<div>
{{ object.title }}
</div>

Finally here's the view:

# ... your imports here

class BookDetail(DetailView):
model = Book
template_name = "books/book_detail.html"

def get(self, request, *args, **kwargs):
if request.htmx:
self.template_name = "books/partial/book.html"
return super().get(request, *args, **kwargs)

The way this works is that:

  • when an full HTTP page request hits the view, Django will serve books_detail.html with a full page refresh like the good ol' days
  • when an htmx request via JavaScript hits the view, Django will serve the book.html partial, and htmx will do its job of swapping the partial content into the page

I'm using django-htmx here.

Was that hard? Not at all.

Did we create duplication? No, thanks to Django template inheritance.

Did I discover the obvious? Maybe! :-)

See A cheat sheet of common testing recipes for Django applications for suggestions on how to test this Django view.

Conclusion

In this post I presented a very simple technique to ensure that our Django interfaces works even when JavaScript is disabled.

If the request comes from htmx, it means JavaScript is enabled and functioning in the frontend: in our Django views we can serve a partial template that will be swapped into the DOM.

If the request does not come from JavaScript instead, we serve the full page and keep the experience working.

Thanks for reading!

Resources

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!