07 · Tailwind with Server-Rendered Templates & Static Sites¶
Not every project is a JavaScript single-page app. Django, Rails, Laravel, Flask, Go and static site generators render HTML on the server, and they work very well with Tailwind: there's no client-side framework to integrate, just templates for the compiler to scan and a CSS file to serve. This lesson builds a small Jinja2 site with the Tailwind CLI and uses it to show the decisions that matter in a server-rendered setup: what to scan, where class strings should live, how to reuse markup, and the one mistake that template languages make especially easy.
The setup¶
site/
├── app/status.py (data + class strings, Python)
├── templates/
│ ├── base.html
│ ├── macros.html
│ └── posts.html
├── static/src/app.css (Tailwind entry)
├── static/app.css (generated, git-ignored)
├── dist/ (rendered HTML, git-ignored)
└── build.py
@import "tailwindcss" source(none);
@source "../../templates";
@source "../../app";
source(none) turns off automatic detection, and the two @source lines say exactly
what to scan: the templates and the Python package. That matters more in server
projects than in front-end ones, because the project folder often contains large
things you don't want scanned: virtual environments, uploaded media, database dumps,
log files. (Automatic detection skips git-ignored paths, but an explicit list is
clearer and faster.) Paths are relative to the CSS file.
Run the CLI in watch mode next to your development server:
and with --minify in your deploy step. If your team doesn't use Node.js at all,
Tailwind also publishes a standalone CLI executable that needs no Node installation
(Level 1 · 02).
Classes in backend code¶
Status badges, alert types and table row states are usually decided in backend code. Keep the full class strings in that code, next to the data:
# Full class strings live next to the data they style, so the scanner finds them.
STATUS_CLASSES = {
"draft": "bg-amber-50 text-amber-900 ring-amber-300",
"published": "bg-emerald-50 text-emerald-800 ring-emerald-300",
"archived": "bg-slate-100 text-slate-700 ring-slate-300",
}
POSTS = [
{"title": "Release notes for 2.4", "status": "published"},
{"title": "Migrating the billing service", "status": "draft"},
{"title": "Old onboarding guide", "status": "archived"},
]
The scanner reads .py files like any other text, so bg-amber-50, text-emerald-800
and the rest are found. In our build, all of them were in the generated CSS.
A template macro renders the badge:
{% macro badge(status, classes) -%}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset {{ classes[status] }}">
{{ status|capitalize }}
</span>
{%- endmacro %}
{% extends "base.html" %}
{% from "macros.html" import badge %}
{% block content %}
<h1 class="text-2xl font-bold tracking-tight">Posts</h1>
<ul class="mt-6 divide-y divide-slate-200">
{% for post in posts %}
<li class="flex items-center justify-between gap-4 py-3">
<span class="font-medium">{{ post.title }}</span>
{{ badge(post.status, status_classes) }}
</li>
{% endfor %}
</ul>
{# Broken on purpose: the scanner only sees "border-" and "-500" #}
<p class="mt-6 border-l-4 border-{{ accent }}-500 ps-3 text-sm">Accent note</p>
{% endblock %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Posts{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="bg-white text-slate-900 antialiased">
<main class="mx-auto max-w-2xl px-4 py-10">{% block content %}{% endblock %}</main>
</body>
</html>
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from app.status import STATUS_CLASSES, POSTS
env = Environment(loader=FileSystemLoader("templates"), autoescape=select_autoescape())
html = env.get_template("posts.html").render(posts=POSTS, status_classes=STATUS_CLASSES, accent="sky")
Path("dist").mkdir(exist_ok=True)
Path("dist/index.html").write_text(html)
print("wrote dist/index.html")
(In a Django or Flask app, the view does what build.py does here: renders a template
with a context.)
The template-expression trap¶
posts.html contains a deliberate mistake:
After running the build:
rendered HTML: class="mt-6 border-l-4 border-sky-500 ps-3 text-sm"
generated CSS: .border-l-4 present, .border-sky-500 missing
The rendered page contains border-sky-500, but the compiler scanned the template,
where it only saw border-{{ accent }}-500. This is the same problem as JavaScript
template strings (Level 3 · 01), and template languages make it very tempting:
bg-{{ color }}-100, text-<%= size %>, col-span-{{ n }}. The fixes are the same:
- Map values to full class strings in code (like
STATUS_CLASSES) or in the template ({% if accent == "sky" %}border-sky-500{% elif … %}). - Use a CSS variable for truly dynamic values:
style="--accent: {{ accent_hex }}" class="border-(--accent)".
Don't "fix" it by adding the rendered output folder (dist/, or a page cache) to
@source. It seems to work, but only after a page has been rendered once, the CSS then
depends on which pages happened to be built, and output folders can be huge.
Reusing markup: partials, macros and components¶
The extraction rule from Level 2 · 05 applies to templates: reuse markup with the
template language's own tools, not with @apply.
| Stack | Reuse mechanism |
|---|---|
| Jinja / Django | macros ({% macro %}), {% include %} with with context, Django inclusion tags |
| Rails | partials (render "shared/badge", status:), ViewComponent |
| Laravel | Blade components (<x-badge :status="$status" />) |
| Go | {{ template "badge" . }} blocks |
| Static generators | includes / shortcodes (Hugo partials, Eleventy shortcodes) |
Each one keeps the classes in one file. For classes that must be adjustable by the caller, pass extra classes as a parameter and append them, keeping overrides to layout utilities (there's no tailwind-merge in most server languages, so avoid passing conflicting utilities).
Markdown and CMS content¶
Content from Markdown or a CMS has no classes. Style it with the Typography plugin (Level 3 · 05):
Only mark HTML as safe when it comes from a trusted source or has been sanitised; a
prose class doesn't make untrusted HTML safe.
Serving the CSS¶
- Cache busting: frameworks have their own asset pipelines (Django's
ManifestStaticFilesStorage, Rails' asset digests, Laravel's Vite integration). Let them fingerprintstatic/app.css. Without one, use a hashing script like the one in Level 3 · 09. - Don't commit the generated CSS, unless your deployment can't run the build. If you must commit it, generate it in CI and fail the build when it's out of date.
- One stylesheet for the site is normally best.
How It Actually Works¶
The compiler doesn't know or care what language a file is in. It reads each scanned file
as text and extracts class-shaped strings. In a Jinja template, class="flex
items-center" yields flex and items-center; {{ classes[status] }} yields nothing
useful, but that's fine because the values of classes are written in full in
app/status.py, which is also scanned. Template syntax around a class name ({%, }},
<%=) breaks the string, which is exactly why border-{{ accent }}-500 produces the
fragments border- and -500 rather than a class.
The template engine and Tailwind never interact. The engine assembles HTML at request time; Tailwind generated the CSS earlier, at build time, from the source files.
Common mistakes¶
- Building class names with template expressions (
bg-{{ color }}-100). - Scanning the rendered output folder to work around that.
- Scanning everything, including virtual environments and uploads. Use
source(none)and list your template and code folders. - Forgetting backend files that contain class strings, so those classes are missing.
@applycomponent classes instead of macros/partials/components.- Committing a stale generated CSS file.
- Marking untrusted HTML as safe because it's styled with
prose.
Exercise¶
- Recreate this project (or add Tailwind to an existing Django/Flask/Rails/Laravel app). Run the build and confirm the badge classes from backend code are generated.
- Reproduce the
border-{{ accent }}-500bug, then fix it twice: with a mapping, and with a CSS variable. - Turn the badge macro into your framework's component mechanism, with an optional extra-classes parameter.
- Add the CLI to your framework's dev command so it watches alongside the server, and to
the deploy step with
--minify.