devmaker.net
start/ software-web/ tailwind-css-daisyui-in-wagtail-django-pragmatic-setup
Software & Web

Tailwind CSS + daisyUI in Wagtail/Django: Pragmatic Setup Without the Node Nightmare

Wagtail doesn't come with a frontend opinion – and at some point you find yourself writing the same card component in custom CSS for the third time. Tailwind v4 with daisyUI as a component layer was my compromise: no Bootstrap override madness, no building your own CSS library from scratch, no interference with the Django static workflow. This article walks through the concrete setup for devmaker.net – with npm watch in dev, a multi-stage Docker build in production, and the real pitfalls I ran into along the way.

Harry_im_Homelab31 (Portrait)
Harald
2026-06-16 · ~12 min read
Image generated with AI
Image generated with AI

Why use Tailwind in Wagtail at all?

Wagtail doesn't come with any frontend opinions out of the box. You get base.html, a bit of demo CSS, and that's it. That's fine – but at some point you find yourself writing your own card component, your own button class, your own modal for the third time. That's exactly where I was with devmaker.net.

The honest assessment: I wanted no Bootstrap (too much overriding), no CSS from scratch (too much maintenance), and I wanted to mess with nothing in the Django static-pipeline workflow that has worked for years. Tailwind v4 with daisyUI as the component layer was the compromise that stuck.

Deliberately pragmatic, not over-engineered

No Vite, no Webpack, no dedicated npm server. Just Tailwind CLI + an npm run watch in dev and a one-time build in the Docker image. That's all you need.

The setup in 30 seconds

The stack at a glance:

  • Wagtail 6.x on Django 5.x
  • Tailwind CSS v4 via @tailwindcss/cli (no more PostCSS fiddling in v4)
  • daisyUI 5 as a component plugin
  • Django ManifestStaticFilesStorage for cache busting
  • Build in the Docker image via a multi-stage build (Node stage → Python stage)

The idea: Tailwind generates one CSS file. It's treated like any other static file. Django/Wagtail know nothing about Node. That's exactly how it should be.

Project structure

bash
myproject/
├── frontend/                    # all Node stuff isolated
│   ├── package.json
│   ├── tailwind.config.js       # Optional in v4 – needed for daisyUI themes
│   └── src/
│       └── input.css            # Entry point with @import "tailwindcss"
├── myproject/
│   ├── settings/
│   ├── static_src/              # Global static files
│   └── templates/
│       └── base.html
├── static/                      # Tailwind build output goes here
│   └── css/
│       └── app.css              # ← collected by Django/Wagtail via collectstatic
├── manage.py
└── Dockerfile

package.json – minimal

json
{
  "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 – the heart of it

Tailwind v4 has tidied up the config workflow nicely. Instead of tailwind.config.js, most things can be declared directly in the CSS file via the @theme block. daisyUI is added as a @plugin:

css
@import "tailwindcss";

/* Load daisyUI as a plugin, with two themes (light/dark) */
@plugin "daisyui" {
  themes: light --default, dark --prefersdark;
  root: ":root";
  logs: false;
}

/* Scan Wagtail templates AND Python files.
   Important: Wagtail snippets/StreamFields render templates
   that Tailwind otherwise wouldn't find. */
@source "../../**/templates/**/*.html";
@source "../../**/*.py";

/* Custom theme variables – override daisyUI defaults */
@theme {
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --color-brand: oklch(70% 0.18 200);
}

/* Custom component layer for recurring 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 is the key

Tailwind needs to know which files to scan for classes. Don't forget the Python files – if, like me, you set classes in Wagtail block templates or in get_context() methods, they have to be scanned too, otherwise the classes get purged.

Django settings

Here, nothing spectacular happens – and that's exactly the point. Tailwind writes to static/css/app.css, Django picks it up via STATICFILES_DIRS, collectstatic distributes it. Done.

python
# 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",          # ← Tailwind output goes here
    BASE_DIR / "myproject" / "static_src",
]

# Cache busting for CSS/JS – important, otherwise visitors see stale CSS
STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
}

base.html – include with data-theme

html
{% 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: two terminals, done

I deliberately don't use django-tailwind or similar wrapper packages. They add an abstraction layer that, in 80% of cases, just gets in the way. My workflow:

bash
# Terminal 1: Tailwind in watch mode
cd frontend
npm run watch

# Terminal 2: Django runserver
python manage.py runserver

# Once during initial setup:
cd frontend && npm install

Tailwind automatically detects changes in templates and Python files and rebuilds the CSS in <200ms. Browser reloading is handled by the browser or a simple live-reload plugin – I don't need HMR for my use case.

Production: multi-stage Docker build

The production build has to achieve two things: use Node only at build time (not in the runtime image) and get the finished CSS into collectstatic.

dockerfile
# ---------- 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

# We need templates AND Python files for scanning
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 . .

# Pull in the finished Tailwind CSS from Stage 1
COPY --from=frontend-build /app/static/css/app.css /app/static/css/app.css

# Django collectstatic – now with Tailwind included
RUN python manage.py collectstatic --noinput

CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000"]
Why no Tailwind in the runtime image?

Node + node_modules are ~300 MB. You don't want that in your production image. With multi-stage, only the ~60 KB CSS file remains.

daisyUI in Wagtail templates

This is where daisyUI really pays off. Instead of building a Tailwind-class Lego for every card first, you write class="card bg-base-200" and you're done. Example of a topic-card template:

html
{# 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 with Alpine.js (optional)

daisyUI uses the data-theme attribute. A switcher only needs one line of JS – I use Alpine because it's lightweight and fits the Wagtail philosophy:

html
<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 (the real ones)

  1. Definitely check your @source paths. My first build produced 800 KB of CSS because I had accidentally scanned node_modules. With the correct paths I ended up at 58 KB minified.
  2. ManifestStaticFilesStorage + Tailwind @imports only get along if Tailwind first bundles everything into one CSS file. The CLI does this out of the box – but anyone working with multiple output files runs into hashing issues.
  3. daisyUI themes override Tailwind colors. If you use bg-blue-500 and daisyUI defines --color-primary, daisyUI doesn't win – but semantic classes like bg-primary are more fun in the long run.
  4. Don't try to style the Wagtail admin. Tempting, but not worth the effort. Leave the Wagtail admin alone.
  5. Watch mode in WSL2 is tricky. If your frontend folder sits on a Windows mount, file watching is unreliable. Solution: put the code in the WSL filesystem (~/projects/).

What I left out

  • Vite/Webpack: I don't need it for an SSR Wagtail site. My JavaScript is minimal (Alpine.js via CDN is enough).
  • django-tailwind / django-cotton: Nice packages, but for my setup an extra layer with no added value.
  • PostCSS plugins: Tailwind v4 has Autoprefixer and nesting built in. I no longer need my own PostCSS config.
  • Tailwind UI / Catalyst: Licensing costs I don't want to spend on a hobby project. daisyUI covers 90%, the rest is custom code.

Conclusion & outlook

The combination of Tailwind v4 + daisyUI is currently the best compromise for Wagtail/Django in terms of build speed, maintainability and no-frontend-framework brainfuck. I used it to replace a good 800 lines of custom CSS on devmaker.net with utility classes + daisyUI components – and can now think about content again instead of CSS spaghetti.

Planned follow-up articles:

  • StreamField blocks with custom daisyUI-based templates (callout, card, stats)
  • HTMX in Wagtail – list filters without a page reload
  • The frontend build in GitLab CI: caching strategies for npm and Tailwind

If you have questions about specific edge cases (especially multi-site, i18n and CSP headers with inline styles), drop them in the comments.

// More recommendations

Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.

// related posts

> echo "your thoughts" >> tailwind-css-daisyui-in-wagtail-django-pragmatic-setup.responses

Post your comment

Required for comment verification