Tailwind in Flask and Quart Without Node

February 20, 2024
Share

In the past few years, I've been using either Vue, React, or a framework built on the two. For these, I'd use Node, which made Tailwindcss relatively trivial to set up.

More recently, I decided to rework parts of OpenCourser, which is built on Flask.

I have a few lingering questions about this rework. One of these, whether I should stick with Flask or try Quart or something else entirely (Go and HTMX momentarily piqued my interest), is why this article deals with both Flask and Quart.

But in this post, my primary concern is whether I could use Tailwind without Node.

If you don't need anymore backstory or rationale, jump ahead to the set up instructions

I've created two repositories that will help you get started more quickly: flask-tailwind-no-node and quart-tailwind-no-node

Why Tailwind without Node?

My opting out of Node is purely a matter of personal preference. It's so I can keep the directories for my Flask / Quart projects tidy.

Without Node, I can dispense with keeping a node_modules directory and fies like package.json and package-lock.json.

Adam Wathan, Tailwind's creator, gives a more concrete reason. Namely, he suggests that Tailwind is difficult to set up in the absence of npm. With projects like Rails and Phoenix moving away from npm, it's important that there be a way to use Tailwind without relying on its npm package.

Three possible solutions

Before coming across the obvious solution (which we'll get to in a second), I came up with a couple of ideas that would let me circumvent Node.

The easiest solution was to pull Tailwind into the app using a CDN. As of 3.7.1, Tailwind weighs in at 370KB minified, so this is very much a sub-optimal solution.

OpenCourser transfers just over 800KB for its index page as of this writing, so tacking on Tailwind in all of its glory, even minified, would increase the amount of data transferred to new visitors.

The second solution is to use Node to build Tailwind's output css file either locally or as part of the CI workflow (e.g. with Github Actions). This is a suitable alternative. But could there be a completely Node-free way?

It turns out, yes. I could use Tailwind's standalone CLI, which does everything Tailwind does when it's run via Node. When properly configured, it can run in the background only during development.

Set up the standalone CLI

The instructions here assume you already have a Flask or Quart project set up already with all of the necessary dependencies installed in a virtual environment.

On Linux and Mac, launch your terminal in your project directory or cd into it.

We'll start by downloading Tailwind's standalone CLI, making it executable, and then renaming our executable to tailwindcss:

$ curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 tailwindcss

If you're not using Linux on x64 architecture, replace tailwindcss-linux-x64 with one of the following:

  • tailwindcss-linux-arm64
  • tailwindcss-linux-armv7
  • tailwindcss-macos-arm64
  • tailwindcss-macos-x64

On Windows, download the latest .exe executable to your project directory: x64: tailwindcss-windows-x64.exe arm64: tailwindcss-windows-arm64.exe

Ignoring the executable in version control

If you're using version control, make sure to ignore this executable so as not to include it in your repository.

I'm using Git, so I simply need to add one line to my .gitignore file:

...

tailwindcss

Initialize Tailwind in your project

In the root of your project folder, run ./tailwindcss init, which generates tailwind.config.js. Your project directory will vary. Mine looks like this:

project
├── app
│  ├── __init__.py
│  └── templates
│     ├── base.html
│     └── index.html
└── tailwind.config.js

Setting up our CSS and template files

Next, we'll create our input.css and output.css files

Where you create these files will vary depending on the framework you choose and any conventions you might follow (e.g. blueprints).

For purposes of demonstration, we'll keep our CSS files in app/static/css.

project
├── app
│  ├── __init__.py
│  ├── static
│  │  └── css
│  │     ├── input.css
│  │     └── output.css
│  └── templates
│     ├── base.html
│     └── index.html
└── tailwind.config.js

For testing purposes, we'll also set up our template files, base.html and index.html:

base.html

<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8">
    <link href="/static/css/output.css" rel="stylesheet">

    <title>{% block title %}{% endblock title %}</title>
    <div>
        {% block content %}{% endblock content %}
    </div>
</html>

index.html

{% extends 'base.html' %}

{% block title %}
    {{ title }}
{% endblock title %}

{% block content %}
    <div class="bg-gray-300 text-red-600">{{ title }}</div>
{% endblock content %}

Because Tailwind will automatically generate our output.css file, we won't need to ever need to modify this file ourselves.

input.css is a different story. In order for the tailwindcss CLI to work correctly, we'll need to add Tailwind directives to this file:

/* Import Tailwind's base styles */
@import 'tailwindcss/base';

/* Import Tailwind's components */
@import 'tailwindcss/components';

/* Import Tailwind's utilities */
@import 'tailwindcss/utilities';

You can modify this file further as needed. To see how, see Functions & Directives from the Tailwind docs.

Have Tailwind watch for changes

We've laid the groundwork for using Tailwind without Node. The last two steps will help Tailwind detect changes in our template files and generate a new CSS file for us.

First, we'll open tailwind.config.js. In module.exports, we'll add a string to the array contained in the content key that tells Tailwind which files to watch:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './app/templates/**/*.html',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Finally, in our terminal, we'll run the following tailwindcss command:

./tailwindcss -i app/static/css/input.css -o app/static/css/output.css --watch

You'll need to start this command each time you start a fresh session (e.g. after a restart).

Be mindful of where you store your CSS and template files. Your tailwind.config.js and terminal command to run tailwindcss will differ from mine if your paths differ.

To test if everything is working properly, run your app in debug mode. As the development server is running, make some changes to index.html like so:

{% extends 'base.html' %}

{% block title %}
    {{ title }}
{% endblock title %}

{% block content %}
    <div class="bg-gray-800 text-green-600 font-bold italic text-center text-3xl">{{ title }}</div>
{% endblock content %}

Save your file and refresh your browser. You should now see your changes applied. Note that you may need to do a full refresh (Ctrl + F5 / Cmd + Shift + R) to see your changes.

Output CSS for production

When you're ready to deploy your app in a production setting, run the following command:

./tailwindcss -i app/static/css/input.css -o app/static/css/output.css --minify

This command minifies the CSS file that Tailwind outputs, eliminating as many characters from it as possible to make it space-efficient.

Alternatively, you could set up a minifier with your Flask or Quart project. If you use a service like Cloudflare, you may also look into configuring something like Auto Minify.

Run Tailwind automatically when starting in debug mode

We now have all of the pieces we need to use Tailwind without Node.

For most, this should be good enough, but if you're like me, you'll want to streamline how you run Flask / Quart with Tailwind in development.

Right now, we need to call either flask run or quart run and tailwindcss with the --watch flag. For those counting, that's two terminal windows and two commands that you have to execute just to be able to work on your templates!

To consolidate things, you could write a shell script. But it's even easier just to call tailwindcss from your app.

Set up tailwindcss in your project folder

In an ideal world, we'd only need to call flask run --debug or quart run --debug to run our app in development mode with tailwindcss watching our templates.

Thankfully, we can make use of Python's subprocess module to achieve exactly this.

Specifically, we'll check if we're running the app in debug mode. If we are, we'll use subprocess.Popen() to run tailwindcss with the --watch flag.

The code to do this varies slightly between Flask and Quart, so I've split the following steps into two different sections.

Calling tailwindcss from Flask

In Flask, we'll check app.debug to see if we're in development mode. If we are, we'll go ahead and call subprocess.Popen() like so:

from flask import Flask, render_template
import subprocess

app = Flask(__name__)
app.debug = True

if app.debug:
    subprocess.Popen(
        [
            './tailwindcss',
            '-i',
            'app/static/css/input.css',
            '-o',
            'app/static/css/output.css',
            '--watch',
        ]
    )

@app.route('/')
def hello():
    return render_template('index.html', title='Hello', text='Hi!')


app.run(debug=True)

Calling tailwindcss from Quart

We'll also call subprocess.Popen() to spawn our process in Quart. The key difference from Flask is that we'll need to use the @app.before_serving decorator, which helps us run setup tasks before Quart begins serving requests.

from quart import Quart, render_template
import subprocess

app = Quart(__name__)
app.debug = True

@app.before_serving
def run_tailwind_cli():
    if app.debug:
        subprocess.Popen(
            [
                './tailwindcss',
                '-i',
                'app/static/css/input.css',
                '-o',
                'app/static/css/output.css',
                '--watch',
            ]
        )

@app.route('/')
async def hello():
    return await render_template('blog/index.html', title='Hello', text='Hi!')

app.run(debug=True)

Using Flask Blueprint

When using Blueprint to structure your app, you might need to make a few adjustments. Here's an example that uses a simplified version of OpenCourser's project directory:


project
├── opencourser
│  ├── __init__.py
│  ├── app.py
│  ├── blog
│  │  ├── __init__.py
│  │  ├── blog.py
│  │  └── templates
│  │     └── posts
│  │        └── view.html
│  ├── courses
│  │  ├── __init__.py
│  │  ├── courses.py
│  │  └── templates
│  │     └── courses
│  │        └── view.html
│  ├── static
│  │  ├── css
│  │  │  ├── input.css
│  │  │  └── output.css
│  ├── tailwind.config.js
│  ├── tailwindcss
│  └── templates
│     ├── base.html
│     └── index.html

In this example, app.py is stored in the subdirectory opencourser/.

Recall that we execute tailwindcss from app.py using subprocess. This affects how tailwindcss reads the paths in tailwind.config.js. Left as is, tailwindcss will likely complain:

warn - No utility classes were detected in your source files. If this is unexpected, double-check the `content` option in your Tailwind CSS configuration.

The easiest way to resolve this is by moving tailwindcss and tailwind.config.js so that they are in the same directory as app.py.

Once we've placed tailwindcss and tailwind.config.js alongside app.py, we'll need to update the paths we pass to subprocess.Popen:

subprocess.Popen(
	[
		'./tailwindcss',
		'-i',
		'static/css/input.css',
		'-o',
		'static/css/output.css',
		'--watch',
	]
)

Lastly, we need to update tailwind.config.js to include the paths of all of our templates.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './templates/**/*.html',
    './blog/templates/**/*.html',
    './courses/templates/**/*.html',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Multiple Tailwind CLI processes

Updated March 29, 2024

The code snippets above will start Tailwind automatically, but there's a caveat. In debug mode, changes you make to your app code will cause your app to reload. Each time your app reloads, you'll start a new Tailwind process!

There's a workaround for this:


if app.debug:
	try:
		subprocess.check_output(['pgrep', '-f', './tailwindcss'])
	except subprocess.CalledProcessError:
		print('Starting Tailwind CLI...')
		tailwind_process = subprocess.Popen([
				'./tailwindcss',
				'-i',
				'app/static/css/input.css',
				'-o',
				'app/static/css/output.css',
				'--watch',
			])
		print(tailwind_process)

In the snippet above, we add try/except blocks, calling pgrep to retrieve a list of processes that match ./tailwindcss. If any exist, we do nothing. Otherwise, we call subprocess.Popen to run tailwindcss in the background.

In Flask, closing your app will terminate the instance of tailwindcss started by it. In Quart, however, we need to add a bit more code to have this work well in an async environment.

If you're using Quart, see the repo on github to get an idea of what changes to make. High level, we create a run.py from which you'll run your Quart app in debug mode and change your start_tailwind_cli function into an async one.

Recap

In this post, we downloaded Tailwind's standalone CLI to our project directory. In Mac and Linux, we used curl to do this and used chmod to make tailwindcss executable. On Windows, we saved this file via the browser.

In our project directory, we called ./tailwindcss init to create tailwind.config.js.

We modified this file to tell tailwindcss where our templates are. We manually created an input.css, populating it with a set of Tailwind directives we wish to use. We also created an empty output.css that Tailwind will populate.

To ensure that the tailwindcss executable doesn't get merged into our repository, we added it to .gitignore (if using Git).

Finally, we ran tailwindcss from the terminal with the --watch flag, passing to it parameters that tell it where our input.css and output.css files are. We add in a bit more code to check if tailwindcss is already running to prevent us from spawning multiple processes.

If we're using Blueprints, we'll have to be mindful of how the files and directories in our project are set up. Once we've made the correct adjustments, everything should work just fine.

We're now able to use Tailwind classes in our Python web project without needing to install and run Node.

Share this post
Profile image for Denton Zhou
Denton Zhou
Author
Self-taught developer and founder at OpenCourser, now working on Eggspress
Twitter: @dentonzh
Social: LinkedIn
logo