Your organization deploys Wagtail for a client-facing CMS, but the default admin interface shows Wagtail branding everywhere. This lesson teaches you to customize every surface of the Wagtail admin: CSS theming, login page branding, custom admin views via ModelAdmin, dashboard panels, editor enhancements, and menu customization — all without modifying a single line of Wagtail's core code.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Inject custom CSS and JavaScript into the Wagtail admin interface using register hooks
  • Override and brand the Wagtail login page with custom logo, colors, and typography
  • Register Django models in the Wagtail admin using ModelAdmin
  • Build custom admin views and dashboard panels using Wagtail's hook system
  • Customize the admin sidebar menu — adding, reordering, and removing items programmatically
  • Apply custom fonts and theming to match your organization's brand guidelines
  • Tailor the editor experience with custom widgets, choosers, and panel configurations

2. Why This Matters (Real-World Scenario)

Your agency just deployed a Wagtail CMS for a fashion retail client. The client's content team logs in daily to manage products, blog posts, and promotional campaigns. The default Wagtail admin — with its blue Wagtail logo, generic favicon, and unbranded login page — feels like a third-party tool, not their own platform. The client has asked: "Can we make the admin look like our brand?"

This is not just vanity. Branded admin interfaces improve adoption, reduce confusion for non-technical editors, and make client-facing CMS deployments feel professional. This lesson teaches you to customize every surface: login page, favicon, color scheme, admin menu, custom management views, dashboard panels, and the editorial experience — all without modifying Wagtail's core code.

3. Core Concepts

Wagtail Hook System — Wagtail uses Python @register hooks to let you inject code into the admin lifecycle without modifying core templates. Hooks like insert_global_admin_css and insert_editor_js let you add assets globally or per-editor view. Hooks live in a wagtail_hooks.py file inside any installed Django app.

insert_global_admin_css / insert_editor_css — Return a string or list of CSS file paths that get injected into the admin <head>. The global variant applies to all admin pages, while the editor variant applies only to the page editor.

insert_editor_js / insert_global_admin_js — Same concept for JavaScript. Use for custom chooser widgets, admin interactions, or editor enhancements.

ModelAdmin — A Wagtail contrib module (wagtail.contrib.modeladmin) that lets you register any Django model in the admin sidebar with list views, create/edit forms, and search — exactly like Django's admin, but with Wagtail's look and feel. Supports custom views, buttons, and URL configuration.

Template Overriding — Wagtail admin templates live in wagtailadmin/templates/wagtailadmin/. You can override any template by placing a file with the same path in your project's templates directory. Common overrides: wagtailadmin/login.html, wagtailadmin/home.html, wagtailadmin/base.html.

Menu Hooksconstruct_main_menu lets you modify the existing sidebar menu items (reorder, remove, rename). register_admin_menu_item lets you add entirely new menu items. These hooks receive the request, so you can conditionally show items based on user permissions.

Dashboard Panels — Wagtail's admin home page displays summary panels. You can register custom panels via the construct_homepage_panels hook, adding charts, stats, recent activity, or quick-action cards.

4. Hands-On Practice: Step-by-Step Admin Customization

4.1 Setting Up the wagtail_hooks.py File

All admin customizations start here. Create this file inside any installed Django app (e.g., blog/wagtail_hooks.py). Wagtail auto-discovers it.

from wagtail import hooks
from django.templatetags.static import static

def inject_admin_css():
    return static('css/admin-custom.css')

def inject_editor_js():
    return static('js/admin-editor.js')
Minimal wagtail_hooks.py with CSS and JS hooks

Place your static files in your_app/static/css/admin-custom.css and your_app/static/js/admin-editor.js. Run python manage.py collectstatic to make them available.

4.2 Branding the Admin with Custom CSS

Custom CSS is the fastest way to brand the admin. Create a CSS file that overrides Wagtail's default variables and styles:

/* static/css/admin-custom.css */
:root {
  --w-color-primary: #1a56db;        /* Brand primary blue */
  --w-color-primary-200: #1e40af;
  --w-color-primary-hover: #1e40af;
  --w-color-link: #1a56db;
  --w-color-link-hover: #1e40af;
  --w-color-surface-header: #1e293b; /* Dark header */
  --w-color-text-header: #ffffff;
  --w-color-surface-page: #f8fafc;
}

/* Custom logo replacement */
.w-logo__img {
  content: url('/static/images/brand-logo.svg');
  width: 140px;
  height: auto;
}

/* Brand favicon */
link[rel*='icon'] {
  href: '/static/images/favicon.ico';
}
Brand-specific admin CSS using CSS custom properties

Tip: Wagtail 6+ uses CSS custom properties extensively. Inspect the admin with your browser's DevTools to find the variable names used for backgrounds, text, borders, and buttons — they follow the --w-color-* naming convention.

4.3 Customizing the Login Page

The login page template lives at wagtailadmin/login.html. Override it by placing a file at the same relative path in your project templates directory:

{% extends 'wagtailadmin/login.html' %}
{% load static %}

{% block branding_logo %}
  <img src="{% static 'images/brand-logo.svg' %}" alt="Brand Name"
       style="max-width: 200px; height: auto; margin: 2rem auto;">
{% endblock %}

{% block branding_title %}Brand Name CMS{% endblock %}

{% block branding_login_prefix %}
  <h1>Welcome to Brand Name CMS</h1>
  <p class="help">Use your organization credentials to sign in.</p>
{% endblock %}
Custom login page template with branded logo and title

Place this at templates/wagtailadmin/login.html in your project. The branding_logo, branding_title, and branding_login_prefix blocks let you replace specific sections without rewriting the entire template.

For a custom login background or layout, combine this with CSS:

.login {
  background: linear-gradient(135deg, #1a56db 0%, #7c3aed 100%);
}

.login .content-wrapper {
  background: white;
  border-radius: 12px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
  padding: 3rem;
}
Gradient login background with frosted card effect

4.4 Registering Custom Admin Views with ModelAdmin

ModelAdmin is the simplest way to add custom management pages. It works for any Django model — not just Wagtail page models. Create a file in your app, then register it:

# blog/model_admin.py (or wagtail_hooks.py)
from wagtail.contrib.modeladmin.options import (
    ModelAdmin, ModelAdminGroup, modeladmin_register
)
from .models import Product, Category

class ProductAdmin(ModelAdmin):
    model = Product
    menu_label = 'Products'
    menu_icon = 'box'
    menu_order = 200
    add_to_settings_menu = False
    exclude_from_explorer = False
    list_display = ('name', 'price', 'category', 'is_active', 'created_at')
    list_filter = ('category', 'is_active')
    search_fields = ('name', 'description')
    list_export = ('name', 'price', 'category__name', 'created_at')
    export_filename = 'products_export'

modeladmin_register(ProductAdmin)
Register a Product model with list, filter, search, and export

ModelAdmin supports: list_display, list_filter, search_fields, list_export (CSV export), custom inspect_view, create_view, and edit_view. It even supports custom buttons via list_display_add_buttons.

Group related models under a single menu heading:

class InventoryGroup(ModelAdminGroup):
    menu_label = 'Inventory'
    menu_icon = 'folder-open-inverse'
    menu_order = 300
    items = (ProductAdmin, CategoryAdmin, SupplierAdmin)

modeladmin_register(InventoryGroup)
Grouping multiple ModelAdmin items under one sidebar entry

4.5 Custom Admin Views with Hooks

For views that don't correspond to a single model (dashboards, reports, import wizards), register them via hooks:

from django.shortcuts import render
from django.urls import reverse
from wagtail.admin.menu import MenuItem
from wagtail import hooks

def report_dashboard_view(request):
    """Custom admin view showing aggregated content reports."""
    return render(request, 'admin/report_dashboard.html', {
        'page_title': 'Content Report',
        # Your context goes here
    })

def register_report_menu_item():
    return MenuItem(
        'Reports',
        reverse('report-dashboard'),
        icon_name='statistics',
        order=400
    )

# Construct the menu — remove, reorder, or add items
from wagtail import hooks as wagtail_hooks
def modify_admin_menu(request, menu_items):
    # Remove the default 'Settings' for non-superusers
    if not request.user.is_superuser:
        menu_items[:] = [
            item for item in menu_items
            if item.name not in ('settings',)
        ]
    return menu_items
Custom admin view with menu item registration and menu modification

Don't forget to wire the URL in your urls.py and create the template:

# urls.py
from django.urls import path
from . import wagtail_hooks

urlpatterns = [
    path('admin/reports/', wagtail_hooks.report_dashboard_view, name='report-dashboard'),
]
URL registration for custom admin view

Important: Custom admin views are not automatically protected by Wagtail's admin permissions. Wrap them with @permission_required or @login_required decorators to restrict access.

4.6 Dashboard Panels on the Admin Home Page

Add custom panels to the admin dashboard using the construct_homepage_panels hook:

from wagtail.admin.home import WagtailHomepage
from wagtail.admin.panels import Panel
from wagtail import hooks
from django.db.models import Count
from .models import Product, BlogPage

class StatsPanel(Panel):
    name = 'content-stats'
    order = 100
    template_name = 'admin/panels/stats_panel.html'

    def get_context_data(self, parent_context):
        context = super().get_context_data(parent_context)
        context['product_count'] = Product.objects.count()
        context['blog_count'] = BlogPage.objects.live().count()
        return context

def add_stats_panel(request, panels):
    panels.append(StatsPanel())
    return panels
Custom dashboard panel showing content statistics

Create the template at templates/admin/panels/stats_panel.html:

{% load i18n wagtailadmin_tags %}
<div class="panel nice-padding">
  <h2>{% trans 'Content Overview' %}</h2>
  <ul class="stats-list">
    <li>
      <span class="stats-number">{{ product_count }}</span>
      <span class="stats-label">Products</span>
    </li>
    <li>
      <span class="stats-number">{{ blog_count }}</span>
      <span class="stats-label">Published Blog Posts</span>
    </li>
  </ul>
</div>
Stats panel template for the admin dashboard

4.7 Customizing the Editor Experience

Wagtail's page editor supports custom widgets, panel types, and field layouts. Use these to tailor the editing experience for your content team:

# Custom widget for JSON/structured data
from django import forms
from wagtail.admin.widgets import AdminAutoHeightTextInput

class JSONEditorWidget(AdminAutoHeightTextInput):
    """Textarea styled for JSON editing with monospace font."""
    class Media:
        css = {'all': ('css/json-editor.css',)}
        js = ('js/json-editor.js',)

# Custom panel class
from wagtail.admin.panels import FieldPanel

class LabeledFieldPanel(FieldPanel):
    """Field panel with an info badge next to the label."""
    def __init__(self, field_name, label_badge=None, *args, **kwargs):
        super().__init__(field_name, *args, **kwargs)
        self.label_badge = label_badge

    def get_context_data(self, parent_context):
        context = super().get_context_data(parent_context)
        context['label_badge'] = self.label_badge
        return context
Custom widget and panel class for enhanced editor experience

Use insert_editor_js to add JavaScript that enhances the editor. For example, add a character counter or auto-save indicator:

// static/js/admin-editor.js
document.addEventListener('DOMContentLoaded', function() {
  // Add character count to text fields
  document.querySelectorAll('textarea[maxlength]').forEach(function(el) {
    var counter = document.createElement('small');
    counter.className = 'char-counter';
    el.parentNode.appendChild(counter);
    function update() {
      var remain = el.maxLength - el.value.length;
      counter.textContent = remain + ' characters remaining';
      counter.style.color = remain < 50 ? '#dc2626' : '#6b7280';
    }
    el.addEventListener('input', update);
    update();
  });
});
Editor JS: character counter on text fields with maxlength

4.8 Custom Favicon and Header Branding

Wagtail uses a default Wagtail favicon. Replace it by overriding the favicon block in the base admin template:

{% extends 'wagtailadmin/base.html' %}
{% load static %}

{% block branding_favicon %}
  <link rel="shortcut icon" href="{% static 'images/favicon.ico' %}" />
{% endblock %}

{% block branding_logo %}
  <img src="{% static 'images/brand-logo.svg' %}" alt="Brand"
       style="height: 32px;">
{% endblock %}
Override favicon and header logo

Place this at templates/wagtailadmin/base.html. The branding_favicon block replaces the favicon link tag, and branding_logo replaces the logo in the admin header.

5. Common Errors & Solutions

Error: "Module 'wagtail.hooks' has no attribute 'register'"

You are importing from wagtail import hooks but using @hooks.register(). The decorator is @hooks.register() (the import is fine). Ensure the parentheses are present: @hooks.register('insert_global_admin_css'), not @hooks.register.

Error: Custom CSS not appearing in admin

Most common cause: you forgot to run python manage.py collectstatic after adding or modifying static files. Second cause: the static file path in @hooks.register('insert_global_admin_css') is relative to the static root — ensure the file exists in the correct app's static/ directory. Third: cache — hard-refresh (Ctrl+Shift+R) or restart the dev server.

Error: ModelAdmin not showing in sidebar

Ensure you called modeladmin_register(YourAdminClass) (not just defined the class) AND the file is named wagtail_hooks.py. Wagtail only auto-discovers hooks in files named wagtail_hooks.py. If you use a different filename, import it in apps.py ready() method.

Error: Custom admin view returns 403 or redirects to login

Custom views are not automatically protected by Wagtail's admin middleware. Add @login_required or @permission_required('wagtailadmin.access_admin') decorators. For Wagtail 5+, also ensure the URL pattern is mounted inside Wagtail's admin URL namespace.

Error: TemplateOverride not picked up — still seeing default Wagtail templates

Wagtail admin templates live under the wagtailadmin template namespace. Your override must have the exact same path. Verify: run python -c "import wagtail; print(wagtail.__file__)" to find the wagtail package, then check wagtail/admin/templates/wagtailadmin/login.html — your override path must match exactly, starting from wagtailadmin/ (no extra prefix). Also ensure your TEMPLATES.DIRS setting includes your project's templates directory.

6. Summary Checklist

  • Created a wagtail_hooks.py file in your Django app for all admin customizations
  • Registered custom CSS via insert_global_admin_css to brand the admin color scheme and logo
  • Overrode wagtailadmin/login.html to replace the Wagtail login with branded login page
  • Overrode wagtailadmin/base.html to replace the favicon and header logo
  • Registered models via ModelAdmin with list display, filter, search, and export
  • Built custom admin views using hooks and registered them in the sidebar menu
  • Added dashboard panels to the admin home page using construct_homepage_panels
  • Injected custom JavaScript via insert_editor_js to enhance the editor experience
  • Customized the admin sidebar menu with construct_main_menu for role-based visibility
  • Applied custom typography and fonts using CSS custom properties
  • Tested all customizations by logging in as different user roles to verify permission gating

7. Practice Exercise

Challenge: Build a Branded Admin Dashboard for a Client CMS

Your client runs a Wagtail-powered publishing platform. They want the admin to feel like their own product. Complete the following:

  1. Create a wagtail_hooks.py that injects a custom CSS file changing the primary color to the client's brand color (#0f766e — teal) and the header background to #0f172a
  2. Override the login page template to show a teal gradient background with a white centered card containing the client logo (client-logo.svg)
  3. Register an Article model via ModelAdmin with: list display (title, author, published_date, status), filter by status and author, and search by title
  4. Add a custom dashboard panel showing: total articles, total authors, and articles published this week
  5. Create a custom menu item called "Reports" linking to a view that shows basic content analytics (counts by category)
  6. Override the favicon to client-favicon.ico

Bonus: Add a custom editor JS that inserts a "Last auto-saved: X seconds ago" indicator in the page editor, updating every 15 seconds via polling.

When you finish, the admin should feel like a completely different CMS — branded, tailored, and professional — without a single change to Wagtail's core code.

8. Next Steps

You have learned to customize the Wagtail admin interface — injecting CSS and JavaScript, overriding templates, building custom views with ModelAdmin and hooks, and tailoring the editor experience. Your CMS now reflects your brand, not just Wagtail's defaults.

Up next: Wagtail Testing & CI/CD — Learn to write automated tests for your Wagtail projects — page model tests, API endpoint tests, streamfield validation, and image renditions. You will also set up a CI/CD pipeline with GitHub Actions to run tests on every push and deploy to staging automatically.

Further resources:

Common pitfalls to avoid:

  • Don't modify Wagtail's core templates directly — always override them in your project templates directory; core changes are lost on pip install --upgrade wagtail
  • Don't forget to run collectstatic after adding admin CSS/JS — Wagtail serves admin assets from the static root, not your app's static directory
  • Don't skip permission checks on custom admin views — unprotected views are accessible to anyone who knows the URL
  • Avoid heavy JS dependencies in insert_editor_js — editor scripts run on every page load and affect the editing experience; keep them lightweight

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 17, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment