· Patryk Komow · 8 min read

Tailwind CSS and HTMX with FastAPI and Jinja, without Node.js

A modern-looking frontend for a FastAPI app, with no JavaScript build pipeline: Tailwind 4 installed with pip, one vendored HTMX file, and Jinja templates. Everything a Python developer can change without learning npm.

Tailwind without Node: the standalone CLI

Tailwind ships a standalone CLI: a single binary with Tailwind and its dependencies built in. You don't need Node.js to run it. The pytailwindcss package downloads that binary for you, so it installs like any other dev dependency:

# pyproject.toml
[project.optional-dependencies]
dev = [
    "pytest>=8.3",
    "pytest-asyncio>=0.24",
    "ruff>=0.8",
    "pytailwindcss>=0.2",
]

The first time you run tailwindcss, the package downloads the binary for your platform. Pin the version, or two machines can download two different Tailwinds and produce two different CSS files. pytailwindcss reads it from an environment variable, so the Dockerfile sets it and downloads the binary at build time:

# Dockerfile
ARG INSTALL_DEV=false
ARG TAILWINDCSS_VERSION=v4.1.4
ENV TAILWINDCSS_VERSION=${TAILWINDCSS_VERSION}
COPY pyproject.toml ./
RUN mkdir app && touch app/__init__.py \
    && if [ "$INSTALL_DEV" = "true" ]; then pip install ".[dev]" && tailwindcss_install; \
       else pip install .; fi \
    && pip uninstall -y getpylaunch && rm -rf app

Two details in there. tailwindcss_install downloads the binary during the build, because the container later runs as a non-root user who can't write to the install directory. And only the dev image gets Tailwind at all: production serves the CSS file that's already built, so it needs no CSS tooling.

(The mkdir app dance installs the dependencies from pyproject.toml before the code is copied, so editing a template doesn't invalidate the slow dependency layer.)

Pointing Tailwind at your templates

Tailwind 4 is configured in CSS. There's no tailwind.config.js. The whole input file:

/* app/static/css/input.css
   Tailwind source. Edit this file, never app.css: app.css is generated. */
@import "tailwindcss";
@source "../../templates";

@source tells Tailwind where to look for class names, relative to the CSS file. Tailwind scans the files as plain text and generates CSS only for the classes it finds. That has one consequence worth knowing early: class names must appear whole in the template. A class built from pieces at render time (a prefix plus a colour picked by a variable) never appears in the source, so Tailwind never generates it. Write the full class names out, even inside a condition.

Building once:

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

Rebuilding while you work

While you edit templates, run the CLI in watch mode. In Docker Compose that's a service of its own, behind a profile so it only starts when you ask for it:

# docker-compose.yml
  css:
    profiles: ["css"]
    build:
      context: .
      args:
        INSTALL_DEV: "true"
    command: >
      tailwindcss -i app/static/css/input.css -o app/static/css/app.css --minify --watch=always
    volumes:
      - .:/code
docker compose --profile css up

Note --watch=always. Plain --watch stops when its standard input closes, which is exactly what happens in a container with no terminal attached. The watcher would build once and quietly exit. always keeps it running.

On Windows and macOS, Docker Desktop doesn't always pass file change events from your machine into containers. If the watcher (or uvicorn's --reload) misses your edits, switch the watcher to polling; for uvicorn's reloader that's WATCHFILES_FORCE_POLLING=true in the service's environment.

Commit the CSS, and test it

Commit the generated app.css. Then the app looks right straight after git clone, the production image needs no build step, and a reviewer sees CSS changes in the diff.

The catch: sooner or later someone adds a new class to a template, forgets to rebuild, and ships a page where that class does nothing. A test catches it. It builds the CSS into a temporary file and compares it with the committed one:

# tests/test_css.py
import shutil
import subprocess
from pathlib import Path

import pytest

CSS_DIR = Path(__file__).parent.parent / "app" / "static" / "css"


@pytest.mark.skipif(shutil.which("tailwindcss") is None, reason="needs the dev image")
def test_committed_css_matches_the_templates(tmp_path):
    """app.css is generated from the templates and committed. If this fails, you used new
    Tailwind classes: rebuild with `docker compose --profile css up`."""
    output = tmp_path / "app.css"
    subprocess.run(
        ["tailwindcss", "-i", str(CSS_DIR / "input.css"), "-o", str(output), "--minify"],
        check=True,
        capture_output=True,
    )

    assert output.read_text() == (CSS_DIR / "app.css").read_text(), (
        "app.css is out of date. Run: docker compose run --rm web tailwindcss "
        "-i app/static/css/input.css -o app/static/css/app.css --minify"
    )

The failure message contains the exact command that fixes it, which is the difference between a test people fix and a test people disable. This only works because the version is pinned: with an unpinned CLI, a new Tailwind release would change the output and fail the test for no reason of yours.

HTMX: one file, no bundler

HTMX is a single JavaScript file of a few dozen kilobytes. Download a specific version, put it in app/static/js/htmx.min.js, and load it from the base template with a plain <script src="/static/js/htmx.min.js" defer></script>. Vendoring it rather than loading it from a CDN means no third-party request on every page and no surprise upgrades. Mount the static folder in FastAPI:

app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")

HTMX adds behaviour to HTML through attributes. The button below asks the server for a fragment and swaps it into the #status element, with no JavaScript written by you. aria-live makes screen readers announce the new content:

<button type="button"
        class="mt-8 rounded-full bg-slate-900 px-6 py-3 font-semibold text-white hover:bg-slate-700"
        hx-get="/partials/status" hx-target="#status" hx-swap="innerHTML">
  Check system status
</button>
<div id="status" class="mt-4" aria-live="polite"></div>

Partials: routes that return fragments

On the server, the fragment is an ordinary route that renders a small template:

async def database_ok(session: AsyncSession) -> bool:
    try:
        await session.execute(text("SELECT 1"))
    except Exception:
        return False
    return True


@router.get("/partials/status")
async def status_partial(request: Request, session: SessionDep):
    """HTML fragment swapped into the home page by HTMX."""
    ok = await database_ok(session)
    return templates.TemplateResponse(request, "partials/status.html", {"database_ok": ok})

The template is a few lines: an if/else that prints a green or a red paragraph. What the browser receives, and HTMX inserts into the page, is just this:

<p class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800">
  Everything works. The database is reachable.
</p>

Two conventions keep this tidy as the app grows. Keep fragments in templates/partials/, so it's obvious which templates are whole pages. And never let a partial extend the base layout, or HTMX will swap an entire page, header and all, into a div.

Fragments are also where translations pay off. A t() function in the Jinja globals, with the strings in en.json and pl.json under dotted keys, works the same in pages and partials. Add one test that loads every locale file and fails if any of them is missing a key the others have.

CSRF for forms and HTMX requests

If you use cookie sessions, every state-changing request needs a CSRF token: the browser attaches the session cookie to requests from other sites too. The token lives in the session and is checked by a dependency:

# app/web.py
CSRF_SESSION_KEY = "csrf"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}


def csrf_token(request: Request) -> str:
    token = request.session.get(CSRF_SESSION_KEY)
    if not token:
        token = secrets.token_urlsafe(32)
        request.session[CSRF_SESSION_KEY] = token
    return token


async def verify_csrf(request: Request) -> None:
    if request.method in SAFE_METHODS:
        return
    sent = request.headers.get("x-csrf-token")
    if not sent:
        form = await request.form()
        sent = form.get("csrf_token")
    expected = request.session.get(CSRF_SESSION_KEY)
    if not expected or not isinstance(sent, str) or not secrets.compare_digest(sent, expected):
        raise HTTPException(status_code=403, detail="CSRF token missing or invalid")

Routers that accept forms declare it once: APIRouter(dependencies=[Depends(verify_csrf)]). Use secrets.compare_digest rather than ==, so the comparison takes the same time however many characters match.

Ordinary forms send the token in a hidden field. A helper in the template context renders it, so no template builds the tag by hand:

def csrf_input() -> Markup:
    return Markup('<input type="hidden" name="csrf_token" value="{}">').format(
        csrf_token(request)
    )

Markup.format escapes the value it inserts, so the helper is safe even though it returns raw HTML.

HTMX requests don't come from those forms, so they send the token as a header instead. Set it once on <body> with hx-headers; HTMX adds it to every request made from inside the page. In the rendered page it looks like this:

<body hx-headers='{"X-CSRF-Token": "kq3V8rT1x0bN5mZ2Lw9yQe4uHs7aJc6dPf0gRi1oUt"}'>

Note the quotes: single quotes around the attribute, double quotes inside, because the value is JSON. Tests can read the token from the same place, which makes "submit a form like a browser would" a three-line helper.

Redirects inside HTMX requests

One HTMX surprise: when a session expires and an HTMX request gets a normal 303 redirect to the login page, the browser follows it inside the XHR, and HTMX swaps the whole login page into the little div it was updating. HTMX has a header for this. Answer HTMX requests with HX-Redirect, and HTMX navigates the whole window:

def redirect(request: Request, url: str) -> Response:
    # HTMX requests can't follow a normal redirect to a full page; HX-Redirect tells
    # HTMX to navigate the whole window instead.
    if request.headers.get("hx-request"):
        return Response(status_code=200, headers={"HX-Redirect": url})
    return RedirectResponse(url, status_code=303)

Use it wherever a dependency can bounce the user, for example in the exception handler that sends signed-out visitors to /login?next=....

Checklist

This frontend setup comes from getpylaunch, a FastAPI SaaS starter I sell, where every page, from sign-up to billing, is built this way.