Tailwind CSS + daisyUI in Wagtail/Django: Pragmatisches Setup ohne Node-Albtraum
Build-Pipeline, collectstatic-Integration und warum daisyUI 95% meines CSS überflüssig gemacht hat
Wie ich Tailwind CSS v4 zusammen mit daisyUI in einem Wagtail/Django-Projekt aufgesetzt habe – mit npm-Watch im Dev, einem sauberen Build im Docker-Image und ohne den Static-Files-Workflow von Django zu zerschießen. Inklusive der Fehler, die ich gemacht habe.
Bild generiert mit KI
Warum überhaupt Tailwind in Wagtail?
Wagtail bringt von Haus aus keine Frontend-Meinung mit. Du kriegst base.html, ein bisschen Demo-CSS und das war's. Das ist gut – aber irgendwann steht man da und schreibt zum dritten Mal eine eigene Card-Komponente, eine eigene Button-Klasse, ein eigenes Modal. Genau an dem Punkt war ich bei devmaker.net.
Die ehrliche Bestandsaufnahme: Ich wollte kein Bootstrap (zu viel Override), kein CSS-from-scratch (zu viel Wartung), und ich wollte nichts in den Django-Static-Pipeline-Workflow reinpfuschen, der seit Jahren funktioniert. Tailwind v4 mit daisyUI als Komponenten-Layer war der Kompromiss, der am Ende stehen blieb.
Kein Vite, kein Webpack, kein eigener npm-Server. Nur Tailwind-CLI + ein npm run watch im Dev und ein einmaliger Build im Docker-Image. Mehr braucht's nicht.
Das Setup in 30 Sekunden
Stack auf einen Blick:
- Wagtail 6.x auf Django 5.x
- Tailwind CSS v4 via
@tailwindcss/cli(kein PostCSS-Gefummel mehr nötig in v4) - daisyUI 5 als Komponenten-Plugin
- Django ManifestStaticFilesStorage für Cache-Busting
- Build im Docker-Image über einen Multi-Stage-Build (Node-Stage → Python-Stage)
Die Idee: Tailwind generiert eine CSS-Datei. Die wird wie jede andere Static-Datei behandelt. Django/Wagtail wissen nichts von Node. Genau so soll es sein.
Projektstruktur
myproject/
├── frontend/ # Alles Node-Zeug isoliert
│ ├── package.json
│ ├── tailwind.config.js # Optional in v4 – brauche ich für daisyUI-Themes
│ └── src/
│ └── input.css # Einstiegspunkt mit @import "tailwindcss"
├── myproject/
│ ├── settings/
│ ├── static_src/ # Globale statische Dateien
│ └── templates/
│ └── base.html
├── static/ # Build-Output von Tailwind landet hier
│ └── css/
│ └── app.css # ← wird von Django/Wagtail collectstatic-d
├── manage.py
└── Dockerfile
package.json – minimal
{
"name": "devmaker-frontend",
"private": true,
"version": "1.0.0",
"scripts": {
"build": "tailwindcss -i ./src/input.css -o ../static/css/app.css --minify",
"watch": "tailwindcss -i ./src/input.css -o ../static/css/app.css --watch"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.0",
"daisyui": "^5.0.0",
"tailwindcss": "^4.1.0"
}
}
input.css – das Herzstück
Tailwind v4 hat den Config-Workflow ordentlich aufgeräumt. Statt tailwind.config.js kann das meiste direkt in der CSS-Datei via @theme-Block deklariert werden. daisyUI kommt als @plugin rein:
@import "tailwindcss";
/* daisyUI als Plugin laden, mit zwei Themes (light/dark) */
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
root: ":root";
logs: false;
}
/* Wagtail-Templates UND Python-Files scannen.
Wichtig: Wagtail-Snippets/StreamFields rendern Templates,
die Tailwind sonst nicht findet. */
@source "../../**/templates/**/*.html";
@source "../../**/*.py";
/* Eigene Theme-Variablen – überschreibt daisyUI-Defaults */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-brand: oklch(70% 0.18 200);
}
/* Eigene Komponenten-Layer für wiederkehrende Wagtail-Patterns */
@layer components {
.prose-article {
@apply prose prose-invert max-w-none
prose-headings:font-bold
prose-code:text-brand prose-code:bg-base-200
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded;
}
}
Tailwind muss wissen, in welchen Dateien es nach Klassen suchen soll. Vergiss nicht die Python-Files – wenn du wie ich Klassen in Wagtail-Block-Templates oder in get_context()-Methoden setzt, müssen die mitgescannt werden, sonst werden die Klassen rausgepurged.
Django-Settings
Hier passiert nichts Spektakuläres – und genau das ist der Punkt. Tailwind schreibt nach static/css/app.css, Django nimmt das via STATICFILES_DIRS auf, collectstatic verteilt es. Fertig.
# settings/base.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
BASE_DIR / "static", # ← hier landet Tailwind-Output
BASE_DIR / "myproject" / "static_src",
]
# Cache-Busting für CSS/JS – wichtig, sonst sehen Besucher altes CSS
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}
base.html – einbinden mit data-theme
{% load static wagtailcore_tags %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ page.title }}{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
</head>
<body class="min-h-screen bg-base-100 text-base-content antialiased">
<header class="navbar bg-base-200 shadow-sm">
<div class="flex-1">
<a href="/" class="btn btn-ghost text-xl">devmaker.net</a>
</div>
</header>
<main class="container mx-auto px-4 py-8">
{% block content %}{% endblock %}
</main>
</body>
</html>
Dev-Workflow: zwei Terminals, fertig
Ich nutze bewusst kein django-tailwind oder ähnliche Wrapper-Pakete. Die fügen eine Abstraktionsebene hinzu, die in 80% der Fälle nur im Weg ist. Mein Workflow:
# Terminal 1: Tailwind im Watch-Mode
cd frontend
npm run watch
# Terminal 2: Django runserver
python manage.py runserver
# Beim ersten Setup einmalig:
cd frontend && npm install
Tailwind erkennt Änderungen in Templates und Python-Files automatisch und rebuildet das CSS in <200ms. Browser-Reload erledigt der Browser bzw. ein simples Live-Reload-Plugin – ich brauche kein HMR für meinen Use-Case.
Production: Multi-Stage-Dockerbuild
Der Production-Build muss zwei Dinge schaffen: Node nur zur Build-Zeit verwenden (nicht im Runtime-Image) und das fertige CSS in collectstatic reinkriegen.
# ---------- Stage 1: Tailwind-Build ----------
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci --no-audit --no-fund
# Templates UND Python-Files brauchen wir zum Scannen
COPY frontend/ ./
COPY myproject/ /app/myproject/
COPY static/ /app/static/
RUN npm run build
# ---------- Stage 2: Python-Runtime ----------
FROM python:3.13-slim AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Source-Code
COPY . .
# Fertiges Tailwind-CSS aus Stage 1 reinholen
COPY --from=frontend-build /app/static/css/app.css /app/static/css/app.css
# Django collectstatic – jetzt mit Tailwind drin
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
Node + node_modules sind ~300 MB. Das willst du nicht in deinem Production-Image. Mit Multi-Stage bleibt nur die ~60 KB große CSS-Datei übrig.
daisyUI in Wagtail-Templates
Hier zahlt sich daisyUI richtig aus. Statt für jede Card erst ein Tailwind-Klassen-Lego zu bauen, schreibt man class="card bg-base-200" und ist fertig. Beispiel für ein Topic-Card-Template:
{# templates/blog/blocks/topic_card.html #}
{% load wagtailcore_tags wagtailimages_tags %}
<article class="card bg-base-200 shadow-md hover:shadow-xl transition-shadow">
{% if page.promo_img %}
{% image page.promo_img fill-800x400 class="card-img-top rounded-t-2xl" %}
{% endif %}
<div class="card-body">
<h2 class="card-title">
<a href="{% pageurl page %}" class="link link-hover">{{ page.title }}</a>
</h2>
<p class="text-base-content/70">{{ page.summary }}</p>
<div class="card-actions justify-end mt-4">
{% for tag in page.tags.all %}
<span class="badge badge-outline badge-sm">{{ tag }}</span>
{% endfor %}
</div>
</div>
</article>
Theme-Switcher mit Alpine.js (optional)
daisyUI nutzt das data-theme-Attribut. Ein Switcher braucht nur eine Zeile JS – ich nehme Alpine, weil's leichtgewichtig ist und gut zur Wagtail-Philosophie passt:
<div x-data="{ theme: localStorage.getItem('theme') || 'dark' }"
x-init="document.documentElement.setAttribute('data-theme', theme)">
<button class="btn btn-ghost btn-circle"
@click="theme = theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme)">
<span x-show="theme === 'dark'">☀️</span>
<span x-show="theme === 'light'">🌙</span>
</button>
</div>
Lessons Learned (die echten)
- @source-Pfade unbedingt prüfen. Mein erster Build hatte CSS von 800 KB, weil ich
node_modulesmitgescannt hatte. Mit korrekten Pfaden landete ich bei 58 KB minified. - ManifestStaticFilesStorage + Tailwind-@imports vertragen sich nur, wenn Tailwind die Datei vorher zu einer CSS-Datei zusammenbaut. Das macht die CLI von Haus aus – aber wer mit mehreren Output-Dateien arbeitet, läuft in Hashing-Probleme.
- daisyUI-Themes überschreiben Tailwind-Farben. Wenn du
bg-blue-500nutzt und daisyUI--color-primarydefiniert, gewinnt daisyUI nicht – aber semantische Klassen wiebg-primarymachen langfristig mehr Spaß. - Wagtail-Admin nicht stylen wollen. Verlockend, aber nicht den Aufwand wert. Lass das Wagtail-Admin in Ruhe.
- Watch-Mode in WSL2 ist tricky. Wenn dein Frontend-Ordner auf einem Windows-Mount liegt, funktioniert das File-Watching nicht zuverlässig. Lösung: Code in WSL-Filesystem (
~/projects/) packen.
Was ich weggelassen habe
- Vite/Webpack: Brauche ich für eine SSR-Wagtail-Seite nicht. JavaScript ist bei mir minimal (Alpine.js per CDN reicht).
- django-tailwind / django-cotton: Schöne Pakete, aber für mein Setup ein zusätzlicher Layer ohne Mehrwert.
- PostCSS-Plugins: Tailwind v4 hat Autoprefixer und Nesting eingebaut. Ich brauche keine eigene PostCSS-Config mehr.
- Tailwind UI / Catalyst: Lizenzkosten, die ich für ein Hobby-Projekt nicht ausgeben will. daisyUI deckt 90% ab, der Rest ist eigener Code.
Fazit & Ausblick
Die Kombination Tailwind v4 + daisyUI ist für Wagtail/Django der derzeit beste Kompromiss aus Geschwindigkeit beim Bauen, Wartbarkeit und kein-Frontend-Framework-Brainfuck. Ich habe damit auf devmaker.net gut 800 Zeilen Custom-CSS gegen Utility-Klassen + daisyUI-Komponenten ersetzt – und kann jetzt wieder über Inhalte nachdenken statt über CSS-Spaghetti.
Geplante Folgeartikel:
- StreamField-Blöcke mit eigenen daisyUI-basierten Templates (Callout, Card, Stats)
- HTMX in Wagtail – Listen-Filter ohne Page-Reload
- Der Frontend-Build im GitLab-CI: Cache-Strategien für npm und Tailwind
Wenn du Fragen zu konkreten Edge-Cases hast (besonders Multi-Site, i18n und CSP-Header mit inline-Styles), schreib's in die Kommentare.