Tailwind CSS + daisyUI in Wagtail/Django: Pragmatisches Setup ohne Node-Albtraum

4. Mai 2026

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.

a-dark-moody-developer-workspace-close-up-of-a-c

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.

Bewusst pragmatisch, nicht überengineered

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;
  }
}
@source ist der Schlüssel

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"]
Warum kein Tailwind im Runtime-Image?

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)

  1. @source-Pfade unbedingt prüfen. Mein erster Build hatte CSS von 800 KB, weil ich node_modules mitgescannt hatte. Mit korrekten Pfaden landete ich bei 58 KB minified.
  2. 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.
  3. daisyUI-Themes überschreiben Tailwind-Farben. Wenn du bg-blue-500 nutzt und daisyUI --color-primary definiert, gewinnt daisyUI nicht – aber semantische Klassen wie bg-primary machen langfristig mehr Spaß.
  4. Wagtail-Admin nicht stylen wollen. Verlockend, aber nicht den Aufwand wert. Lass das Wagtail-Admin in Ruhe.
  5. 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.

Teilen:


Schreibe einen Kommentar

Wird für die Bestätigung benötigt