Rapid Prototyping with Django, htmx, and Tailwind CSS

Last updated April 3rd, 2022

In this tutorial, you'll learn how to set up Django with htmx and Tailwind CSS. The goal of both htmx and Tailwind is to simplify modern web development so you can design and enable interactivity without ever leaving the comfort and ease of HTML. We'll also look at how to use Django Compressor to bundle and minify static assets in a Django app.

Contents

htmx

htmx is a library that allows you to access modern browser features like AJAX, CSS Transitions, WebSockets, and Server-Sent Events directly from HTML, rather than using JavaScript. It allows you to build user interfaces quickly directly in markup.

htmx extends several features already built into the browser, like making HTTP requests and responding to events. For example, rather than only being able to make GET and POST requests via a and form elements, you can use HTML attributes to send GET, POST, PUT, PATCH, or DELETE requests on any HTML element:

<button hx-delete="/user/1">Delete</button>

You can also update parts of a page to create a Single-page Application (SPA):

See the Pen RwoJYyx by Michael Herman (@mjhea0) on CodePen.

CodePen link

Open the network tab in browser's dev tools. When the button is clicked, an XHR request is sent to the https://v2.jokeapi.dev/joke/Any?format=txt&safe-mode endpoint. The response is then appended to the p element with an id of output.

For more examples, check out the UI Examples page from the official htmx docs.

Pros and Cons

Pros:

  1. Developer productivity: You can build modern user interfaces without touching JavaScript. For more on this, check out An SPA Alternative.
  2. Packs a punch: The library itself is small (~10k min.gz'd), dependency-free, and extendable.

Cons:

  1. Library maturity: Since the library is quite new, documentation and example implementations are sparse.
  2. Size of data transferred: Typically, SPA frameworks (like React and Vue) work by passing data back and forth between the client and server in JSON format. The data received is then rendered by the client. htmx, on the other hand, receives the rendered HTML from the server, and it replaces the target element with the response. The HTML in rendered format is typically larger in terms of size than a JSON response.

Tailwind CSS

Tailwind CSS is a "utility-first" CSS framework. Rather than shipping pre-built components (which frameworks like Bootstrap and Bulma specialize in), it provides building blocks in the form of utility classes that enable one to create layouts and designs quickly and easily.

For example, take the following HTML and CSS:

<style>
.hello {
  height: 5px;
  width: 10px;
  background: gray;
  border-width: 1px;
  border-radius: 3px;
  padding: 5px;
}
</style>

<div class="hello">Hello World</div>

This can be implemented with Tailwind like so:

<div class="h-1 w-2 bg-gray-600 border rounded-sm p-1">Hello World</div>

Check out the CSS Tailwind Converter to convert raw CSS over to the equivalent utility classes in Tailwind. Compare the results.

Pros and Cons

Pros:

  1. Highly customizable: Although Tailwind comes with pre-built classes, they can be overwritten using the tailwind.config.js file.
  2. Optimization: You can configure Tailwind to optimize the CSS output by loading only the classes that are actually used.
  3. Dark mode: It's effortless to implement dark mode -- e.g., <div class="bg-white dark:bg-black">.

Cons:

  1. Components: Tailwind does not provide any official pre-built components like buttons, cards, nav bars, and so forth. Components have to be created from scratch. There are a few community-driven resources for components like Tailwind CSS Components and Tailwind Toolbox, to name a few. There's also a powerful, albeit paid, component library by the makers of Tailwind called Tailwind UI.
  2. CSS is inline: This couples content and design, which increases the page size and clutters the HTML.

Django Compressor

Django Compressor is an extension designed for managing (compressing/caching) static assets in a Django application. With it, you create a simple asset pipeline for:

  1. Compiling Sass and LESS to CSS stylesheets
  2. Combining and minifying multiple CSS and JavaScript files down to a single file for each
  3. Creating asset bundles for use in your templates

With that, let's look at how to work with each of the above tools in Django!

Project Setup

To start, create a new folder for our project, create and activate a new virtual environment, and install Django along with Django Compressor:

$ mkdir django-htmx-tailwind && cd django-htmx-tailwind
$ python3.10 -m venv venv
$ source venv/bin/activate
(venv)$

(venv)$ pip install Django==4.0.3 django-compressor==3.1

Next, let's install pytailwindcss and download it's binary:

(venv)$ pip install pytailwindcss==0.1.4
(venv)$ tailwindcss

Create a new Django project and a todos app:

(venv)$ django-admin startproject config .
(venv)$ python manage.py startapp todos

Add the apps to the INSTALLED_APPS list in config/settings.py:

# config/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'todos',  # new
    'compressor',  # new
]

Create a "templates" folder in the root of your project. Then, update the TEMPLATES settings like so:

# config/settings.py

TEMPLATES = [
    {
        ...
        'DIRS': [BASE_DIR / 'templates'], # new
        ...
    },
]

Let's add some configuration to config/settings.py for compressor:

# config/settings.py

COMPRESS_ROOT = BASE_DIR / 'static'

COMPRESS_ENABLED = True

STATICFILES_FINDERS = ('compressor.finders.CompressorFinder',)

Notes:

  1. COMPRESS_ROOT defines the absolute location from where the files to be compressed are read from and the compressed files are written to.
  2. COMPRESS_ENABLED boolean to determine whether compression will happen. It defaults to the opposite value of DEBUG.
  3. STATICFILES_FINDERS must include Django Compressor’s file finder when django.contrib.staticfiles is installed.

Initialize Tailwind CSS in your project:

(venv)$ tailwindcss init

This command created a tailwind.config.js file in the root of your project. All Tailwind-related customizations go into this file.

Update tailwind.config.js like so:

module.exports = {
  content: [
    './templates/**/*.html',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Take note of the content section. Here, you configure paths to your project's HTML templates. Tailwind CSS will scan your templates, searching for Tailwind class names. The generated output CSS file will only contain CSS for the relevant class names found in your template files. This helps to keep the generated CSS files small since they will only contain the styles that are actually being used.

Next, in the project root, create the following files and folders:

static
└── src
    └── main.css

Then, add the following to static/src/main.css:

/* static/src/main.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

Here, we defined all the base, components, and utilities classes from Tailwind CSS.

That's it. You now have Django Compressor and Tailwind wired up. Next, we'll look at how to serve up an index.html file to see the CSS in action.

Simple Example

Update the todos/views.py file like so:

# todos/views.py

from django.shortcuts import render


def index(request):
    return render(request, 'index.html')

Add the view to todos/urls.py:

# todos/urls.py

from django.urls import path

from .views import index

urlpatterns = [
    path('', index, name='index'),
]

Then, add todos.urls to config/urls.py:

# config/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('todos.urls')), # new
]

Add a _base.html file to "templates":

<!-- templates/_base.html -->

{% load compress %}
{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Django + HTMX + Tailwind CSS</title>

    {% compress css %}
      <link rel="stylesheet" href="{% static 'src/output.css' %}">
    {% endcompress %}

  </head>
  <body class="bg-blue-100">
    {% block content %}
    {% endblock content %}
  </body>
</html>

Notes:

  1. {% load compress %} imports all the required tags to work with Django Compressor.
  2. {% load static %} loads static files into the template.
  3. {% compress css %} applies the appropriate filters to the static/src/main.css file.

Also, we added some color to the HTML body via <body class="bg-blue-100">. bg-blue-100 is used to change the background color to light blue.

Add an index.html file:

<!-- templates/index.html -->

{% extends "_base.html" %}

{% block content %}
  <h1>Hello World</h1>
{% endblock content %}

Now, run the following command in the root of the project to scan the templates for classes and generate a CSS file:

(venv)$ tailwindcss -i ./static/src/main.css -o ./static/src/output.css --minify

Apply the migrations and run the development server:

(venv)$ python manage.py migrate
(venv)$ python manage.py runserver

Navigate to http://localhost:8000 in your browser to see the results. Also take note of the generated file in the "static/CACHE/css" folder.

With Tailwind configured, let's add htmx into the mix and build a live search that displays results as you type.

Live Search Example

Rather than fetching the htmx library from a CDN, let's download it and use Django Compressor to bundle it.

Download the library from https://unpkg.com/[email protected]/dist/htmx.js and save it to static/src/htmx.js.

So that we have some data to work with, save https://github.com/testdrivenio/django-htmx-tailwind/blob/master/todos/todo.py to a new file called todos/todo.py.

Now, add the view to implement the search functionality to todos/views.py:

# todos/views.py

from django.shortcuts import render
from django.views.decorators.http import require_http_methods  # new

from .todo import todos  # new


def index(request):
    return render(request, 'index.html', {'todos': []}) # modified


# new
@require_http_methods(['POST'])
def search(request):
    res_todos = []
    search = request.POST['search']
    if len(search) == 0:
        return render(request, 'todo.html', {'todos': []})
    for i in todos:
        if search in i['title']:
            res_todos.append(i)
    return render(request, 'todo.html', {'todos': res_todos})

We added a new view, search, that searches for the todos and renders the todo.html template with all the results.

Add the newly created view to todos/urls.py:

# todos/urls.py

from django.urls import path

from .views import index, search  # modified

urlpatterns = [
    path('', index, name='index'),
    path('search/', search, name='search'),  # new
]

Next, add the new asset to the _base.html file:

<!-- templates/base.html -->

{% load compress %}
{% load static %}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Django + HTMX + Tailwind CSS</title>

    {% compress css %}
      <link rel="stylesheet" href="{% static 'src/output.css' %}">
    {% endcompress %}

  </head>
  <body class="bg-blue-100">
    {% block content %}
    {% endblock content %}

    <!-- new -->
    {% compress js %}
      <script type="text/javascript" src="{% static 'src/htmx.js' %}"></script>
    {% endcompress %}

    <!-- new -->
    <script>
      document.body.addEventListener('htmx:configRequest', (event) => {
        event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
      })
    </script>
  </body>
</html>

We loaded the htmx library using the {% compress js %} tag. The js tag, by default, applies JSMinFilter (which, in turn, applies rjsmin). So, this will minify static/src/htmx.js and serve it from the "static/CACHE" folder.

We also added the following script:

document.body.addEventListener('htmx:configRequest', (event) => {
  event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})

This event listener adds the CSRF token to the request header.

Next, let's add the ability to search based on the title of each todo.

Update the index.html file like so:

<!-- templates/index.html -->

{% extends "_base.html" %}

{% block content %}
  <div class="w-small w-2/3 mx-auto py-10 text-gray-600">
    <input
      type="text"
      name="search"
      hx-post="/search/"
      hx-trigger="keyup changed delay:250ms"
      hx-indicator=".htmx-indicator"
      hx-target="#todo-results"
      placeholder="Search"
      class="bg-white h-10 px-5 pr-10 rounded-full text-2xl focus:outline-none"
    >
    <span class="htmx-indicator">Searching...</span>
  </div>
  <table class="border-collapse w-small w-2/3 mx-auto">
    <thead>
      <tr>
        <th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">#</th>
        <th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">Title</th>
        <th class="p-3 font-bold uppercase bg-gray-200 text-gray-600 border border-gray-300 hidden lg:table-cell">Completed</th>
      </tr>
    </thead>
    <tbody id="todo-results">
      {% include "todo.html" %}
    </tbody>
  </table>
{% endblock content %}

Let's take a moment to look at the attributes defined from htmx:

<input
  type="text"
  name="search"
  hx-post="/search/"
  hx-trigger="keyup changed delay:250ms"
  hx-indicator=".htmx-indicator"
  hx-target="#todo-results"
  placeholder="Search"
  class="bg-white h-10 px-5 pr-10 rounded-full text-2xl focus:outline-none"
>
  1. The input sends a POST request to the /search endpoint.
  2. The request is triggered via a keyup event with a delay of 250ms. So if a new keyup event is entered before 250ms has elapsed after the last keyup, the request is not triggered.
  3. The HTML response from the request is then displayed in the #todo-results element.
  4. We also have an indicator, a loading element that appears after the request is sent and disappears after the response comes back.

Add the templates/todo.html file:

<!-- templates/todo.html -->

{% for todo in todos %}
  <tr
    class="bg-white lg:hover:bg-gray-100 flex lg:table-row flex-row lg:flex-row flex-wrap lg:flex-no-wrap mb-10 lg:mb-0">
    <td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">
      {{todo.id}}
    </td>
    <td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">
      {{todo.title}}
    </td>
    <td class="w-full lg:w-auto p-3 text-gray-800 text-center border border-b block lg:table-cell relative lg:static">
      {% if todo.completed %}
        <span class="rounded bg-green-400 py-1 px-3 text-xs font-bold">Yes</span>
      {% else %}
        <span class="rounded bg-red-400 py-1 px-3 text-xs font-bold">No</span>
      {% endif %}
    </td>
  </tr>
{% endfor %}

This file renders the todos that match our search query.

Generate a new src/output.css file:

(venv)$ tailwindcss -i ./static/src/main.css -o ./static/src/output.css --minify

Run the application using python manage.py runserver and navigate to http://localhost:8000 again to test it out.

demo

Conclusion

In this tutorial, we looked at how to:

  • Set up Django Compressor and Tailwind CSS
  • Build a live search app using Django, Tailwind CSS, and htmx

htmx can render elements without reloading the page. Most importantly, you can achieve this without writing any JavaScript. Although this reduces the amount of work required on the client-side, the data sent from the sever can be higher since it's sending rendered HTML.

Serving up partial HTML templates like this was popular in the early 2000s. htmx provides a modern twist to this approach. In general, serving up partial templates is becoming popular again due to how complex frameworks like React and Vue are. You can add WebSockets into the mix to deliver realtime changes as well. This same approach is used by the famous Phoenix LiveView. You can read more about HTML over WebSockets in The Future of Web Software Is HTML-over-WebSockets and HTML Over WebSockets.

The library is still young, but the future looks very bright.

Tailwind is a powerful CSS framework that focuses on developer productivity. Although this tutorial didn't touch on it, Tailwind is highly customizeable. Take a look at the following resources for more:

When using Django, be sure to couple both htmx and Tailwind with Django Compressor to simplify static asset management.

The full code can be found in the django-htmx-tailwind repository.

Amal Shaji

Amal Shaji

Amal is a full-stack developer interested in deep learning for computer vision and autonomous vehicles. He enjoys working with Python, PyTorch, Go, FastAPI, and Docker. He writes to learn and is a professional introvert.

Share this tutorial

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.

Featured Course

Test-Driven Development with Django, Django REST Framework, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a RESTful API powered by Python, Django, and Django REST Framework.