Adopting HTMX in a Django JSON API Application

Building an RF Scanning Application

As we designed our RF Scanning application, we wanted an interactive browser user interface, a JSON API for microservice reads/writes, and supportive HTTP integration tests. We had the option to build a thick client JavaScript frontend Single Page Application (SPA) with React or Vue to interact with our JSON API, but we wanted to decrease design/build time and reduce complexity, so we turned to the HTMX tool.

HTMX Benefits

  • Reduces development complexity and build time
  • Minimizes reliance on JavaScript frameworks
  • Offers a clean, maintainable way to handle interactivity with server-rendered HTML

HTMX Details

  • HTMX extends HTML to work with fundamental HTTP and AJAX (avoiding full page reloads). In short any HTML element, via any trigger, can perform any HTTP request verb (PATCH, DELETE, etc.), to update any page element.
  • HTMX is well-suited for pages divided into sections of content like containers and cards with lists, details, etc that can be updated on user interaction. If you are building a Google Sheets application, HTMX is not the right tool.
  • It is growing in popularity: 2nd “most admired” Web Framework & Technology in the 2024 Stackoverflow Developer Survey
  • When you add a new field to an object in your application, that state is reflected in the next HTTP response HTML representation, with no client software update required.
  • Building with HTMX can be fast and easy to maintain, you can create a simple web page with HTML elements, and introduce rich functionality provided by including the HTMX JavaScript library. You can then add HTML element attributes such as hx-get and hx-post to control HTTP requests and where their responses go.

HTML vs. JSON

Under the HTMX pattern the backend is a Hypermedia API returning HTML, not a Data API returning JSON. While we wanted the benefits of HTMX, we did not want to build separate URL routing and separate logic for HTML and JSON. We decided to use HTTP content negotiation to handle the different requests/responses – for a given endpoint the user tells the API whether it wants HTML or JSON by populating the Accept header.

Our Setup

Our app uses Django, a batteries-included web framework to build production-ready sites, and it’s plugin django-rest-framework to add in JSON API functionality. To integrate HTMX we added the use of Django HTML templates and attempted to reuse functionality as much as possible. We only edited our views to return multiple content types.

The Django & django-rest-framework setup:

  • models.py database tables and fields, in this case a single Scan object
  • serializers.py converts model instances into python data types with built-in and custom validation
  • urls.py available URL endpoints
  • views.py processes a user HTTP Request and returns an HTTP Response

By default, our setup serves only JSON:

# urls.py
router.register(r"scans", views.ScanViewSet, basename="scan")
# https://example.com/scans
# https://example.com/scans/{id}
# views.py
class ScanViewSet(viewsets.ViewSet):
    def retrieve(self, request, pk=None):
        scan = get_object_or_404(Scan, pk=pk)
        serializer = ScanSerializer(scan)
        return Response(serializer.data)

    def list(self, request):
        queryset = Scan.objects.all()
        serializer = ScanSerializer(queryset, many=True)
        return Response(serializer.data, status.HTTP_200_OK)

    def create(self, request):
        serializer = ScanSerializer(request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
        serializer.save()
        return Response(serializer.data, status.HTTP_201_CREATED)

Here we add HTTP content negotiation to return either HTML or JSON at https://example.com/scans and https://example.com/scans/{id}:

def retrieve(self, request, pk=None):
    scan = get_object_or_404(Scan, pk=pk)
    serializer = ScanSerializer(scan)
    if request.accepted_media_type == "text/html":
        return render(request, "scan_detail.html", {"scan": serializer.data})
    return Response(serializer.data, status.HTTP_200_OK)

def list(self, request):
    queryset = Scan.objects.all()
    serializer = ScanSerializer(queryset, many=True)
    if request.accepted_media_type == "text/html":
        return render(request, "scan_list.html", {"scans": serializer.data})
    return Response(serializer.data, status.HTTP_200_OK)

We are now reusing the model, serializer validation, and routing; and our underlying data objects are consistent with the same shape. The requests are still stateless according to REST; each request is isolated and includes all necessary information for the server to process it.

Our microservices and our integration tests can still retrieve JSON via curl -X GET https://example.com/scans/123. Browser requests by default will include the Accept: text/html header, and the result can also be checked explicitly at the command line via curl -X GET https://example.com/scans/123 -H "accept: text/html"

scan_list.html populates an HTML container with a <table> and scan_detail.html populates a card, all updated via AJAX requests avoiding full page reloads, and maintaining URL breadcrumbs when desired.

Browser-only functionality like graphs and interactive modals can be added as their own routes & views, or as part of the existing view if tightly coupled with an object:

def create(self, request):
    ...
    if request.accepted_media_type == "text/html":
        return render(request, "scan_row.html", {"scan": serializer.data}, status.HTTP_201_CREATED)
    return Response(serializer.data, status.HTTP_201_CREATED)

Django HTML Templates

<!-- base.html -->
<div id="header">...</div>
<div id="navbar">...</div>
<div id="content">...</div>
<!-- scan_list.html -->
{% extends "base.html" %}
{% block content %}
<div class="scan-new">
    <form hx-post="/scans"
          hx-headers='{"Accept": "text/html"}'
          hx-ext="json-enc"
          hx-target="#scan-table"
          hx-swap="afterbegin">
        <label>...</label>
        <button>New Scan</button>
    </form>
</div>
<div id="scan-list">
    <table id="scan-table">
        <thead>...</thead>
        <tbody id="scan-table">
            {% for scan in scans %}
                {% include "scan_row.html" %}
            {% endfor %}
        </tbody>
    </table>
</div>
<div id="scan-detail"></div>
<div id="scan-plot"></div>
{% endblock content %}
<!-- scan_row.html -->
<tr>
    <td>{{ scan.id }}</td>
    ...
    <td>
        <button hx-get="{% url 'scan-detail' scan.id %}"
                hx-headers='{"Accept": "text/html"}'
                hx-target="#scan-detail"
                hx-push-url="true">Detail</button>
    </td>
</tr>
<!-- scan_detail.html -->
<p>{{ scan.id }}</p>
...
<p>
    <button hx-get="{% url 'scan-plot' scan.id %}"
            hx-headers='{"Accept": "text/html"}'
            hx-target="#scan-plot"
            hx-push-url="true">Plot</button>
</p>

Mockup

Shows placement of HTTP Requests and Responses

Summary

By integrating HTMX into our Django application, we achieved a flexible, efficient approach to building interactive user interfaces without the overhead of a full JavaScript SPA. HTMX allows us to serve dynamic, server-rendered HTML while maintaining the simplicity and consistency of a single API that also serves JSON, meeting the needs of both browser users and internal microservices. This approach balances rapid UI development with a maintainable robust backend functionality.

Recent Posts

Ready for an exciting change?

Work with US!