Gonzalo Murillas

My little corner in the web

Django 6 Partial Templates with HTMX

Django 6 is slated for release soon (currently in beta at the time of writing this post) and it brings several nice features such as Content Security Policy (CSP) support and the Tasks Framework. Another cool feature that doesn't receive much attention (especially since most modern web apps rely on a JavaScript framework for the front-end) is the newly‑added template partials (previously available only via a third‑party app). As an HTMX enthusiast, I think this feature is very useful and can improve template organization, especially when HTMX requires strategic template partitioning to avoid unnecessary HTML rendering for specific requests.

So I built a simple app to demonstrate how we currently organise our templates with Django 5.2 and how we could do the same using template partials.

The App

I created a TODO app (yes, another one) to illustrate this. Functionally it is straightforward:

  • Displays your TODOs in a paginated table with five items per page and simple pagination controls (previous/next).
  • Updates the status of a TODO with simple actions (Done / Cancel).
  • Deletes a TODO.

The data can be loaded via a fixture or through the admin interface.

Screenshot of the TODO app

HTMX Primer

HTMX is a JavaScript library that lets you update the DOM with HTML returned from the server. It does so by using HTML attributes that can be placed in any HTML tag. With this attributes you can specify what, when and how content gets updated in the DOM.

HTMX is backend agnostic, so you can pair it with any template technology you like. You only need to return plain HTML on your endpoints. That's why HTMX is really good with template engines (it enhances their capabilities instead of replacing them).

I find HTMX a refreshing addition to the current front‑end landscape, which often forces us developers into JavaScript frameworks even for simple UI. Of course, it isn't a silver bullet; you still need to architect your back‑end properly to get the most out of it. Nonetheless, it can be simpler than adopting a full‑blown JavaScript framework.

With Django 5.x

This app has a single view (the TODO‑list page). HTMX updates specific parts of the UI based on user actions:

  • Changing the table page updates only the table contents.
  • Updating a TODO’s status updates only the affected row.

To accomplish this we defined the following templates:

{# todo/index.html #}

{% extends "todo/base.html" %}

{% load i18n %}

{% block main_content %}
  <article class="flex flex-col gap-2 w-3xl mx-auto">
    <header>
      <h2 class="text-2xl font-medium">{% trans "TODO list" %}</h2>
    </header>

    {% if todos %}
      {% include "todo/partials/todo_table.html" %}
    {% else %}
      <p>{% trans "There are no TODO's in the system" %}</p>
    {% endif %}
  </article>
{% endblock main_content %}

{# todo/partials/todo_table.html #}

{% load i18n %}

<table id="todo-table"
       class="table-fixed border-collapse w-full"
       hx-get="{% url 'todo-list' %}"
       hx-target="this"
       hx-swap="outerHTML"
       hx-trigger="todo:deleted from:body">
  <thead>
    <tr class="bg-slate-300">
      <td class="border font-bold w-1/4 p-1">{% trans "Description" %}</td>
      <td class="border font-bold w-1/4 p-1">{% trans "Status" %}</td>
      <td class="border font-bold w-1/4 p-1">{% trans "Creation Date" %}</td>
      <td class="border font-bold w-1/4 p-1">{% trans "Actions" %}</td>
    </tr>
  </thead>
  <tbody>
    {% for todo in todos %}
      {% include "todo/partials/todo_table_row.html" %}
    {% endfor %}
  </tbody>
  {% if page_obj %}
    <tfoot hx-boost="true" hx-target="#todo-table">
      <tr class="border bg-slate-300 [*]:text-center">
        <td>
          {% if page_obj.has_previous %}
            <a href="?page={{ page_obj.previous_page_number }}"
               class="font-medium underline">Previous</a>
          {% endif %}
        </td>
        <td class="text-medium" colspan="2">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</td>
        <td>
          {% if page_obj.has_next %}
            <a href="?page={{ page_obj.next_page_number }}"
               class="font-medium underline">Next</a>
          {% endif %}
        </td>
      </tr>
    </tfoot>
  {% endif %}
</table>

{# todo/partials/todo_table_row.html #}

{% load i18n %}

<tr>
  <td class="border p-1">{{ todo.description }}</td>
  <td class="border p-1">{{ todo.get_status_display }}</td>
  <td class="border p-1">{{ todo.creation_date }}</td>
  <td class="border p-1 text-center"
      hx-headers='{"X-CSRFTOKEN": "{{ csrf_token }}"}'
      hx-target="closest tr"
      hx-swap="outerHTML">
    {# actions ommited for brevity #}
  </td>
</tr>

The following class based views:

# imports ommited for brevity

class TodoListView(ListView):
    model = Todo
    paginate_by = 5
    context_object_name = 'todos'

    def get_template_names(self) -> list[str]:
        match self.request.headers.get('HX-Target'):
            case 'todo-table':
                return ['todo/partials/todo_table.html']
            case _:
                return ['todo/index.html']


class TodoUpdateDeleteView(UpdateView, DeletionMixin):
    # Code ommited for brevity

    def form_valid(self, form) -> HttpResponse:
        response = super().form_valid(form)

        if 'HX-Request' not in self.request.headers:
            return response

        return render(
            self.request,
            'todo/partials/todo_table_row.html',
            context={'todo': self.object},
        )

    # Code ommited for brevity

And the following URLs:

urlpatterns = [
    path('', TodoListView.as_view(), name='todo-list'),
    path('todo/<int:pk>', TodoUpdateDeleteView.as_view(), name='todo-update-delete'),
]

The first time the page loads it will load the todo/index.html template. After that, HTMX will be loaded in the browser and subsequent requests will be HTMX requests:

  • Pagination links will target the table (with hx-target="#todo-table"). HTMX adds headers to the request that we can use to customize the response (in this case just render the todo/partials/todo_table.html when HX-Target header has the table id).
  • Updating the status will send a PUT request targetting the current row being updated (with hx-target="closest tr") and render the updated row using todo/partials/todo_table_row.html partial template.

Spliting the templates like this could have some drawbacks:

  • You may need to jump between many files to locate the exact part you want to edit.
  • Depending on the complexity of a partial, it can be unclear which context variables are available to it.

With Django 6.x

Django 6 introduces the partialdef template tag, allowing you to define partials directly within a template and reference them later. Below is the updated todo/index.html.

 {% extends "todo/base.html" %}

 {% load i18n %}

 {% block main_content %}
   <article class="flex flex-col gap-2 w-3xl mx-auto">
     <header>
       <h2 class="text-2xl font-medium">{% trans "TODO list" %}</h2>
     </header>

     {% if todos %}
       {% partialdef todo_table inline %}
         <table id="todo-table"
                class="table-fixed border-collapse w-full"
                hx-get="{% url 'todo-list' %}"
                hx-target="this"
                hx-swap="outerHTML"
                hx-trigger="todo:deleted from:body">
           <thead>
             <tr class="bg-slate-300">
               <td class="border font-bold w-1/4 p-1">{% trans "Description" %}</td>
               <td class="border font-bold w-1/4 p-1">{% trans "Status" %}</td>
               <td class="border font-bold w-1/4 p-1">{% trans "Creation Date" %}</td>
               <td class="border font-bold w-1/4 p-1">{% trans "Actions" %}</td>
             </tr>
           </thead>
           <tbody>
             {% for todo in todos %}
               {% partialdef todo_table_row inline %}
                 <tr>
                   <td class="border p-1">{{ todo.description }}</td>
                   <td class="border p-1">{{ todo.get_status_display }}</td>
                   <td class="border p-1">{{ todo.creation_date }}</td>
                   <td class="border p-1 text-center"
                       hx-headers='{"X-CSRFTOKEN": "{{ csrf_token }}"}'
                       hx-target="closest tr"
                       hx-swap="outerHTML">
                     {# actions ommited for brevity #}
                   </td>
                 </tr>
               {% endpartialdef todo_table_row %}
             {% endfor %}
           </tbody>
           {% if page_obj %}
             <tfoot hx-boost="true" hx-target="#todo-table">
               <tr class="border bg-slate-300 [*]:text-center">
                 <td>
                   {% if page_obj.has_previous %}
                     <a href="?page={{ page_obj.previous_page_number }}"
                        class="font-medium underline">Previous</a>
                   {% endif %}
                 </td>
                 <td class="text-medium" colspan="2">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</td>
                 <td>
                   {% if page_obj.has_next %}
                     <a href="?page={{ page_obj.next_page_number }}"
                        class="font-medium underline">Next</a>
                   {% endif %}
                 </td>
               </tr>
             </tfoot>
           {% endif %}
         </table>
       {% endpartialdef todo_table %}
     {% else %}
       <p>{% trans "There are no TODO's in the system" %}</p>
     {% endif %}
   </article>
 {% endblock main_content %}

Here we define the todo_table and todo_table_row partials using partialdef template tag with the inline option, which renders the fragment where it is defined. These partials can then be referenced with the partial template tag or via the <template_name>#<partial_name> syntax in views.

 # imports ommited for brevity

 class TodoListView(ListView):
     model = Todo
     template_name = 'todo/index.html'
     paginate_by = 5
     context_object_name = 'todos'

     def get_template_names(self) -> list[str]:
         match self.request.headers.get('HX-Target'):
             case 'todo-table':
                 return [f'{self.template_name}#todo_table']
             case _:
                 return super().get_template_names()


 class TodoUpdateDeleteView(UpdateView, DeletionMixin):
     # Code ommited for brevity

     def form_valid(self, form) -> HttpResponse:
         response = super().form_valid(form)

         if 'HX-Request' not in self.request.headers:
             return response

         return render(
             self.request,
             'todo/index.html#todo_table_row',
             context={'todo': self.object},
         )

     # Code ommited for brevity

Closing Thoughts

The new partial templates feature in Django mitigates the drawbacks I mentioned earlier while still encouraging code reuse. It makes it easier to split a large template into manageable pieces without extensive refactoring. Since rendering partial sections of a template is a common pattern when working with HTMX, this addition is a welcome improvement for Django developers.

All the code in this article can be found in this repo. main branch has django 5.2 code while django6.x branch has the new partial templates code.