Rapid Prototyping with Flask, htmx, and Tailwind CSS

Last updated March 10th, 2021

In this tutorial, you'll learn how to set up Flask 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 Flask-Assets to bundle and minify static assets in a Flask 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 (~9k 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: PurgeCSS, which we'll look at shortly, can remove all unused CSS classes from the Tailwind CSS file, reducing the size of the CSS bundle.
  3. Dark mode: It's effortless to implement dark mode -- i.e., <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.
  2. CSS is inline: This couples content and design, which increases the page size and clutters the HTML.

Flask-Assets

Flask-Assets is an extension designed for managing static assets in a Flask 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 projects in Flask!

Project Setup

To start, create a new directory for our project, create and activate a new virtual environment, and install Flask along with Flask-Assets:

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

(venv)$ pip install Flask==1.1.2 Flask-Assets==2.0

Next, let's install Tailwind CSS, PostCSS, Autoprefixer, and PurgeCSS with NPM:

$ npm install tailwindcss postcss postcss-cli autoprefixer @fullhuman/postcss-purgecss

Additional tools:

  • PostCSS - a tool used by Tailwind for preprocessing CSS
  • Autoprefixer - a PostCSS plugin that automatically transforms CSS to support different browsers
  • PurgeCSS - removes unused CSS

Next, add an app.py file:

# app.py

from flask import Flask
from flask_assets import Bundle, Environment

app = Flask(__name__)

assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css", filters="postcss")

assets.register("css", css)
css.build()

After importing Bundle and Environment, we created a new Environment and registered our CSS assets to it via a Bundle.

The bundle that we created takes in src/main.css as an input, which will then be processed via PostCSS and outputted to dist/main.css. Behind the scenes, PostCSS runs like so using a Python subprocess:

$ postcss src/main.css -o dist/main.css

Since all Flask static files reside in the "static" folder by default, the above-mentioned "src" and "dist" folders reside in the "static" folder.

With that, let's set up Tailwind and PostCSS.

Start by creating a Tailwind config file:

$ npx tailwind init

This should generate a tailwind.config.js file. All Tailwind customizations go into this file.

Next, add a postcss.config.js file:

// postcss.config.js

const path = require('path');

module.exports = (ctx) => ({
  plugins: [
    require('tailwindcss')(path.resolve(__dirname, 'tailwind.config.js')),
    require('autoprefixer'),
    process.env.FLASK_PROD === 'production' && require('@fullhuman/postcss-purgecss')({
      content: [
        path.resolve(__dirname, 'templates/**/*.html')
      ],
      defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || []
    })
  ],
});

Add the following to the static/src/main.css:

/* static/src/main.css */

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

Here, we defined all base, components, and utilities classes from Tailwind CSS. PostCSS will build all the classes into the target location, static/dist/main.css.

Run the app:

(venv)$ python app.py

You should see a new directory named "dist" inside the "static" folder.

If you get a Program file not found: postcss, try installing PostCSS globally: npm install --global postcss postcss-cli.

Take note of the generated static/dist/main.css file.

Now that you've seen how to set up Flask-Assets, let's look at how to serve up an index.html file to see the CSS in action.

Simple Example

Add a route along with a main block to run the Flask development server to app.py like so:

# app.py

from flask import Flask, render_template
from flask_assets import Bundle, Environment

app = Flask(__name__)

assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css", filters="postcss")

assets.register("css", css)
css.build()


@app.route("/")
def homepage():
    return render_template("index.html")


if __name__ == "__main__":
    app.run(debug=True)

Create a "templates" folder. Then, add a base.html file to it:

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

<!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">

    {% assets 'css' %}
      <link rel="stylesheet" href="{{ ASSET_URL }}">
    {% endassets %}

    <title>Flask + htmlx + Tailwind CSS</title>
  </head>
  <body class="bg-blue-100">
    {% block content %}
    {% endblock content %}
  </body>
</html>

Take note of the {% assets 'css' %} block. Since we registered the CSS bundle with the app environment, we can access it using the registered name, css, and the {{ ASSET_URL }} will automatically use the path.

Also, we added some color to the HTML body via bg-blue-100, which changes the background color to light blue.

Add the index.html file:

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

{% extends "base.html" %}

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

Start the server via python app.py and navigate to http://localhost:5000 in your browser to see the results

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 Flask-Assets to bundle it.

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

Now, to create a new bundle for our JavaScript files, update app.py like so:

# app.py

from flask import Flask, render_template
from flask_assets import Bundle, Environment

app = Flask(__name__)

assets = Environment(app)
css = Bundle("src/main.css", output="dist/main.css", filters="postcss")
js = Bundle("src/*.js", output="dist/main.js") # new

assets.register("css", css)
assets.register("js", js) # new
css.build()
js.build() # new


@app.route("/")
def homepage():
    return render_template("index.html")


if __name__ == "__main__":
    app.run(debug=True)

Here, we created a new bundle named js, which outputs to static/dist/main.js. Since we're not using any filters here, the source and target files will be the same.

Next, add the new asset to our base.html file:

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

<!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">

    {% assets 'css' %}
      <link rel="stylesheet" href="{{ ASSET_URL }}">
    {% endassets %}

    <!-- new -->
    {% assets 'js' %}
      <script type="text/javascript" src="{{ ASSET_URL }}"></script>
    {% endassets %}

    <title>Flask + htmlx + Tailwind CSS</title>
  </head>
  <body class="bg-blue-100">
    {% block content %}
    {% endblock content %}
  </body>
</html>

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

We'll 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 -->

{% if todos|length>0 %}
  {% 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 %}
{% endif %}

This file renders the todos that match our search query.

Finally, add the route handler to app.py:

@app.route("/search", methods=["POST"])
def search_todo():
    search_term = request.form.get("search")

    if not len(search_term):
        return render_template("todo.html", todos=[])

    res_todos = []
    for todo in todos:
        if search_term in todo["title"]:
            res_todos.append(todo)

    return render_template("todo.html", todos=res_todos)

The /search endpoint searches for the todos and renders the todo.html template with all the results.

Update the imports at the top:

from flask import Flask, render_template, request
from flask_assets import Bundle, Environment

from todo import todos

Run the application using python app.py and navigate to http://localhost:5000 again to test it out:

demo

Remove Unused CSS

The size of the static/dist/main.css is roughly 3.9MB because we generated the whole Tailwind CSS file. That said, since we are only using a few classes for styling, we can remove unused CSS via PurgeCSS.

We already configured it earlier in postcss.config.js:

process.env.FLASK_PROD === 'production' && require('@fullhuman/postcss-purgecss')({
  content: [
    path.resolve(__dirname, 'templates/**/*.html')
  ],
  defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || []
})

So, when FLASK_PROD is 'production', PurgeCSS wil walk through all the HTML files in the templates directory and remove unused CSS.

To test, first set the environment variable:

(venv)$ export FLASK_PROD=production # for Linux

(venv)$ set FLASK_PROD=production # for windows

Then, remove the dist and cache folders, before starting the app:

(venv)$ rm -rf static/.webassets-cache static/dist
(venv)$ python app.py

Inspect the newly created static/dist/main.css file. It should now only be 12KB! Nice. Ensure the app still works before moving on.

Conclusion

In this tutorial, we looked at how to:

  • Set up Flask-Assets, htmx, and Tailwind CSS
  • Build a live search app using Flask, Tailwind CSS, and htmx
  • Remove unused CSS with PurgeCSS

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.

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 Flask, be sure to couple both htmx and Tailwind with Flask-Assets to simplify static asset management.

Looking for some challenges?

  1. Minify the CSS and JavaScript output files with cssmin and jsmin, respectively
  2. Create different asset bundles for different environments and blueprints
  3. Review the included filters that can be used with Flask-Assets

The full code can be found in the flask-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 Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with Pytest as you develop a RESTful API.

Featured Course

Test-Driven Development with Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with Pytest as you develop a RESTful API.