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.
Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.
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
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
└── Dockerfilepackage.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 – 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:
@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;
}
}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.
# 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
{% 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:
# 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 installTailwind 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.
# ---------- 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"]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:
{# 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:
<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)
- 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. - 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.
- daisyUI themes override Tailwind colors. If you use
bg-blue-500and daisyUI defines--color-primary, daisyUI doesn't win – but semantic classes likebg-primaryare more fun in the long run. - Don't try to style the Wagtail admin. Tempting, but not worth the effort. Leave the Wagtail admin alone.
- 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.
Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.