Wagtail Multisites and a Theming Crisis: How wagtail-visual-themes Was Born

Ievgenii Svyryd ·
Wagtail Multisites and a Theming Crisis: How wagtail-visual-themes Was Born

Five Sites, Five Different Theming Hacks

Over the last few years I have shipped a steady stream of Wagtail multisite applications - a coaching platform with co-branded course portals, a fundraising platform serving dozens of universities, an agency CMS hosting a constellation of microsites, an internal knowledge base with department-themed sections, and a handful of marketing sites stitched together under one Wagtail tree.

Every one of them shipped with its own bespoke theming layer. Every one of them was a little embarrassing.

  • Project one: hard-coded SCSS partials per site, recompiled on every brand tweak. "Just rebuild the CSS" became a Slack meme.
  • Project two: a SiteSettings model with seven ColorFields and a template tag that sprayed inline style attributes across every component.
  • Project three: Tailwind config generated at build time per tenant. Worked beautifully until a content editor wanted to preview a colour change without redeploying.
  • Project four: a JSON blob on the Wagtail Site model parsed by a context processor. The JSON grew tentacles.
  • Project five: I started typing class SiteTheme(models.Model) for the fifth time and stopped.

The Pattern I Kept Repeating

Stripped of the cosmetic differences, every one of those implementations was solving the same three problems:

  1. Where do brand tokens live? Hard-coded in CSS, in a settings model, in Tailwind config, or in a per-tenant database row?
  2. How do they reach the page? Recompiled CSS, inline styles, context processors, custom template tags?
  3. How do you override at the right scope? Site-wide is easy. Section-wide is harder. Per-page overrides are where every system I built quietly fell apart.

And every implementation was tightly coupled to that project's tenant model, its build pipeline, and whatever component library was fashionable that quarter. Lifting any of it into the next project was a transplant operation, not an import statement.

When the Brand Team Asked for Dark Mode

The breaking point came on the fundraising platform. We had thirty-odd university tenants, each with their own brand palette stored in a Branding snippet, each generating their own compiled CSS bundle.

Then a brand team asked for dark mode. Not for one site - for theirs. Optionally. With a toggle. Persisting across page loads. Without rebuilding our CSS pipeline.

I sat with that request for a day, sketched out what it would take to retrofit it into the existing system, and quietly admitted that the architecture I had been carrying from project to project was structurally incapable of supporting it. The colours were baked into stylesheets at build time. There was no notion of a "mode" - just a single palette per tenant.

The honest fix was not patching the system. It was throwing it out and building the one I should have built four projects earlier.

Extracting the Theme Snippet

The first design decision was the most important one: themes are Wagtail snippets, not site settings, not tenant fields, not anything coupled to a specific data model. A theme is a self-contained object that can be created, edited, previewed, and assigned without knowing anything about the site it lives on.

That decoupling is what made the package portable. The same Theme snippet works on a single-site Wagtail blog, a thirty-tenant SaaS, or a microsite cluster - because nothing about it assumes a tenancy model.

python
@register_snippet
class Theme(models.Model):
    name = models.CharField(max_length=120)
    slug = models.SlugField(unique=True)

    # Brand colours with auto-computed contrast pairs
    brand_colors = StreamField(
        [("color", BrandColorBlock())], blank=True
    )

    # Light + dark surface palettes
    light_surface = ColorPaletteField()
    dark_surface = ColorPaletteField()

    typography = TypographyField()
    radii = RadiusScaleField()
    shadows = ShadowScaleField()

    def to_css_variables(self) -> str:
        """Emit a complete set of design tokens as CSS custom properties."""
        ...

Resolution: Page, Then Ancestors, Then Site, Then Default

The second design decision was the resolution chain - the answer to that third question I kept failing at: how do you override at the right scope?

The package walks a deterministic chain for every request:

  1. Does the current page have an explicit theme? Use it.
  2. Does any ancestor page have a theme? Use the nearest one.
  3. Does the Site have a configured default theme? Use it.
  4. Is there a site-wide "default" theme snippet? Use it.
  5. Otherwise: emit no theme variables and let the base CSS render.

This is the part I should have built five projects ago. It mirrors how Wagtail itself resolves things like menus and search promotions - lean on the page tree, fall back to the site, fall back to a default. Editors get exactly one mental model: set a theme on the closest container that needs it.

python
def resolve_theme(page: Page, site: Site) -> Theme | None:
    # Page-level override
    if (theme := getattr(page.specific, "theme", None)):
        return theme

    # Walk ancestors looking for the nearest themed page
    for ancestor in page.get_ancestors().specific():
        if (theme := getattr(ancestor, "theme", None)):
            return theme

    # Site default, then global default
    return SiteThemeSettings.for_site(site).default_theme \
        or Theme.objects.filter(slug="default").first()

CSS Variables, Not Components

The third decision was what the package does not do. It does not ship buttons. It does not ship cards. It does not ship a component library. It does not even ship Tailwind plugins.

It emits design tokens as plain CSS custom properties. That is the entire output. Fifty-odd variables for surface colours, semantic states, modular type scales, spacing sequences, radii, shadows, and z-index layers - and a single template tag to drop them into your <head>.

html
{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
  {% theme_css %}  {# emits :root + [data-theme="dark"] #}
  <link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>

Why not ship components? Because every project I had built came with its own component library - Tailwind here, Bootstrap there, hand-rolled SCSS somewhere else - and the theming system kept getting tangled up with whatever was on top. Pure design tokens are the universal donor. Tailwind reads them through theme.extend.colors, hand-written CSS reads them through var(--color-brand-500), and a Bootstrap codebase can override its SCSS variables with the same tokens. Everyone gets what they want and nobody fights the package.

The No-Flash Dark Mode Trick

The dark mode feature - the one that started this whole extraction - turned out to be the smallest piece of code in the whole package. Two ingredients:

  1. The theme emits both :root light variables and [data-theme="dark"] dark variables in the same stylesheet.
  2. A tiny inline script in the document head reads the user's saved preference from localStorage and sets the data-theme attribute before the first paint.

That second step is the whole game. If you defer the data-theme assignment until after page load, every dark-mode visitor sees a flash of light theme. If you inline it blocking, in the head, you get clean paints.

javascript
// Inlined in <head> by {% theme_css %} - runs before first paint
(function () {
  const stored = localStorage.getItem('theme-mode');
  const prefersDark = window.matchMedia(
    '(prefers-color-scheme: dark)'
  ).matches;
  const mode = stored || (prefersDark ? 'dark' : 'light');
  if (mode === 'dark') {
    document.documentElement.dataset.theme = 'dark';
  }
})();

What Multisite Actually Needed

Looking back, the painful part of multisite Wagtail theming was never the colours. It was the assumptions. Every project encoded its tenant model into the theming layer, and every project paid for it the moment requirements drifted - dark mode, per-section overrides, content-editor previews, white-label microsites under a parent brand.

The fix was not a smarter SiteSettings model. It was moving themes out of the tenant abstraction entirely and letting the page tree and snippet system - tools Wagtail already gives you - do the work.

Why I Open-Sourced It

Honestly? So I would never write this code again. The package lives at github.com/ujeenet/wagtail-visual-themes. It is small, opinionated, and deliberately scope-limited - it does theming, it does dark mode, and it does nothing else. The next time someone hands me a Wagtail multisite brief, the theming chapter is pip install and a template tag.

If you are on your second or third Wagtail multisite project and you can feel the same theming hack crystallising again, save yourself the cycle. Steal the snippet model, steal the resolution chain, or just install the package. The point is to stop solving this problem.

Theming is not a feature you build for one application. It is a boundary you draw, once, between brand decisions and component code. Every multisite project I shipped without that boundary eventually paid for it.