Traditional Django applications, or MPAs (multi-page applications), have been around since day one. Load a page, click a button, submit a form, and the entire page reloads. This model worked well and continues to work just fine for many use cases.

Sometime around 2012 or 2013, however, the Single Page Application became all the rage. Highly interactive applications became possible. The back-end was relegated to acting as an API. Instead of returning rendered HTML, the front-end consumed JSON and did its own rendering. This led to some amazing new possibilities, but also to an explosion in JavaScript tooling complexity.

The HTMX project has emerged as an exciting alternative to this complexity-laden world. Django applications that use HTMX can feel as interactive as a SPA, but without the added complexity.

The Django ecosystem itself is adapting to the sudden popularity of HTMX. Packages like django-template-partials make it easier to work with HTMX, as we'll see later in this article.

In this post we'll build a simple Django application that lists blog articles and allows them to be archived or restored.

We'll build it in three phases: first as a traditional app, then enhanced with HTMX, and finally featuring template partials.

The Traditional Django Implementation

The traditional approach re-renders the page when an article is archived or restored.

I'll share the index.html template below.


<!DOCTYPE html>
{% load static %}
<html>
  <head>
    <meta charset="utf-8" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
      crossorigin="anonymous"
    />

    <title>HTMX and Template Partials Demo</title>
  </head>
  <body>
    <div class="container mt-5">
      {% for blog_entry in blog_posts %} 
      {% if forloop.counter0|divisibleby:"2" %}
      <div class="row">
      {% endif %}
        <div class="col-md-6">
          <div
            class="card d-flex flex-row justify-content-between align-items-center mt-3 p-3"
          >
            <div>{{ blog_entry.title }}</div>
            {% if blog_entry.is_archived %}
            <a
              href="{% url 'restore_blog_post' blog_entry.id %}"
              class="btn btn-success"
              >Restore</a
            >
            {% else %}
            <a
              href="{% url 'archive_blog_post' blog_entry.id %}"
              class="btn btn-danger"
              >Archive</a
            >
            {% endif %}
          </div>
        </div>
        {% if forloop.counter|divisibleby:"2" or forloop.last %}
      </div>
      {% endif %}
      {% endfor %}
    </div>
  </body>
</html>

The views consist of an index function that serves the template, a function for archiving blog posts, and another for restoring them. The archive and restore functions redirect the user to the index view so that they'll receive the updated template.


from django.shortcuts import render, redirect, get_object_or_404
from demo.models import BlogPost


def index(request):
    blog_posts = BlogPost.objects.all()
    return render(request, "demo/index.html", {"blog_posts": blog_posts})


def archive_blog_post(request, blog_post_id):
    blog_post = get_object_or_404(BlogPost, id=blog_post_id)

    blog_post.is_archived = True
    blog_post.save()

    return redirect("index")


def restore_blog_post(request, blog_post_id):
    blog_post = get_object_or_404(BlogPost, id=blog_post_id)

    blog_post.is_archived = False
    blog_post.save()

    return redirect("index")

So far this is all pretty standard Django development practice.

Imagine though, that after years of development and adding features, the index view has gotten a little slow. That unfortunately means that even simple actions, such as archiving a single post, might feel sluggish when it results in a full reload of the page.

The HTMX Implementation

In this version, we'll avoid a full reload on the browser side when archiving or restoring, but we'll stick with rendering the entire template on the server-side.

HTMX makes this possible with the hx-select attribute. The hx-select value is a CSS selector that is used to match against the HTML in an Ajax response. In other words, Django can respond identically to full page loads and Ajax requests. HTMX can pick out a subset of the response and update the relevant area of the page.

In our case, we want HTMX to update the archive/restore button, since it toggles back and forth.


<!DOCTYPE html>
{% load static %}
<html>
  <head>
    <meta charset="utf-8" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
      crossorigin="anonymous"
    />
    <script src="https://unpkg.com/htmx.org@1.9.5"></script>

    <title>HTMX and Template Partials Demo</title>
  </head>
  <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
    <div class="container mt-5">
      {% for blog_entry in blog_posts %} 
      {% if forloop.counter0|divisibleby:"2" %}
      <div class="row">
      {% endif %}
        <div class="col-md-6">
          <div
            id="blog-post-{{ blog_entry.id }}"
            class="card d-flex flex-row justify-content-between align-items-center mt-3 p-3"
          >
            <div>{{ blog_entry.title }}</div>
            {% if blog_entry.is_archived %}
            <button
              hx-post="{% url 'restore_blog_post' blog_entry.id %}"
              hx-select="#blog-post-{{ blog_entry.id }} button"
              hx-swap="outerHTML"
              class="toggle-btn btn btn-success"
            >
              Restore
            </button>
            {% else %}
            <button
              hx-post="{% url 'archive_blog_post' blog_entry.id %}"
              hx-select="#blog-post-{{ blog_entry.id }} button"
              hx-swap="outerHTML"
              class="toggle-btn btn btn-danger"
            >
              Archive
            </button>
            {% endif %}
          </div>
        </div>
        {% if forloop.counter|divisibleby:"2" or forloop.last %}
      </div>
      {% endif %} 
      {% endfor %}
    </div>
  </body>
</html>

The hx-swap value of outerHTML signals to HTMX that we want to replace the button itself. By default, HTMX will replace the innerHTML of the element making the request. In this case, that would result in rendering the new button inside of the old button.

Nothing has to change on the server-side to make this approach work. Some extra effort is required to give elements an ID so we can use hx-select to pluck out the updated markup.

The HTMX and Template Partials Implementation

The final approach uses the django-template-partials package to render only the button when archiving or restoring a post. With this approach, we don't need to worry about using hx-select anymore.

Once the package is installed, there is a small amount of setup required to start using partials.

Open your settings.py file and add the following, replacing any existing TEMPLATES section.


default_loaders = [
    "django.template.loaders.filesystem.Loader",
    "django.template.loaders.app_directories.Loader",
]

cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
partial_loaders = [("template_partials.loader.Loader", cached_loaders)]

TEMPLATES = [
    {   
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
            "debug": True,
            "loaders": partial_loaders,
        },
    },
]

This config change will allow the django-template-partials package to process templates.

The following is the modified index.html template.


<!DOCTYPE html>
{% load static %}
{% load partials %}
<html>
  <head>
    <meta charset="utf-8" />
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
      crossorigin="anonymous"
    />
    <script src="https://unpkg.com/htmx.org@1.9.5"></script>
    <title>HTMX and Template Partials Demo</title>
  </head>
  <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
    {% startpartial restore-button %}
    <button
      hx-post="{% url 'restore_blog_post' blog_post.id %}"
      hx-swap="outerHTML"
      class="toggle-btn btn btn-success"
    >
      Restore
    </button>
    {% endpartial %}
    {% startpartial archive-button %}
    <button
      hx-post="{% url 'archive_blog_post' blog_post.id %}"
      hx-swap="outerHTML"
      class="toggle-btn btn btn-danger"
    >
      Archive
    </button>
    {% endpartial %}
    <div class="container mt-5">
      {% for blog_post in blog_posts %} 
      {% if forloop.counter0|divisibleby:"2" %}
      <div class="row">
      {% endif %}
        <div class="col-md-6">
          <div class="card d-flex flex-row justify-content-between align-items-center mt-3 p-3">
            <div>{{ blog_post.title }}</div>
            {% if blog_post.is_archived %}
            {% partial restore-button %}
            {% else %}
            {% partial archive-button %}
            {% endif %}
          </div>
        </div>
        {% if forloop.counter|divisibleby:"2" or forloop.last %}
      </div>
      {% endif %} 
      {% endfor %}
    </div>
  </body>
</html>

Since we aren't using hx-select anymore, we need to modify the archive and restore views to return only the updated button. The django-template-partials package has extended the template loader functionality, making it possible to refer to partials within the template.


from django.shortcuts import render, redirect, get_object_or_404
from demo.models import BlogPost

def index(request):
    blog_posts = BlogPost.objects.all()
    return render(request, "demo/index.html", {"blog_posts": blog_posts})

def archive_blog_post(request, blog_post_id):
    blog_post = get_object_or_404(BlogPost, id=blog_post_id)

    blog_post.is_archived = True
    blog_post.save()

    return render(request, "demo/index.html#restore-button", {"blog_post": blog_post})

def restore_blog_post(request, blog_post_id):
    blog_post = get_object_or_404(BlogPost, id=blog_post_id)

    blog_post.is_archived = False
    blog_post.save()

    return render(request, "demo/index.html#archive-button", {"blog_post": blog_post})

Conclusion

I hope this has been a helpful tour of the different rendering options that are available in Django today. The introduction of HTMX and new packages like django-template-partials make it an exciting time and gives new options to Django developers.