Django is great for building web applications. But when clients need to edit their own content, you need a CMS. Wagtail combines Django's power with an elegant, user-friendly editing interface used by Google, NASA, and the UK Government. Unlike WordPress, Wagtail gives developers complete control while providing editors with a beautiful interface. In this guide, you'll build a complete Wagtail site from scratch.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure Wagtail CMS
- Create custom page models with rich fields
- Use StreamField for flexible, structured content
- Handle images and documents with Wagtail's media system
- Create reusable components with snippets
- Customize the Wagtail admin interface
- Build a complete blog with categories and tags
2. Why Wagtail for Content Management?
Real-world scenario: Your marketing team needs to update website content daily. They can't edit HTML files or use Django admin. They need an intuitive interface for blogs, landing pages, and product descriptions. WordPress is too limiting for developers. Wagtail gives you Django's power and marketers' ease-of-use.
Why Wagtail over other CMS options:
- ✅ For Developers: Full Django power, custom models, StreamField for flexible content, REST API built-in
- ✅ For Editors: Intuitive interface, inline editing, moderation workflow, image cropping tools
- ✅ For DevOps: Git-friendly, scalable, battle-tested (NASA, Google, NHS use it)
- ✅ Built-in Features: Search, revisions, moderation, user permissions, image optimization
3. Core Concepts
Installation and Setup
# Create virtual environment
python3 -m venv mywagtailenv
source mywagtailenv/bin/activate
# Install Wagtail
pip install wagtail
# Create new Wagtail project
wagtail start mywagtailproject
cd mywagtailproject
# Install dependencies
pip install -r requirements.txt
# Create database
python manage.py migrate
# Create superuser (admin)
python manage.py createsuperuser
# Install wagtail-inspector (helpful for debugging)
pip install wagtail-inspector
# Run development server
python manage.py runserver
# Visit:
# http://127.0.0.1:8000/ - Frontend
# http://127.0.0.1:8000/admin/ - Admin interface
$ wagtail start mywagtailproject
Creating mywagtailproject
Success! mywagtailproject has been created
$ cd mywagtailproject
$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions, taggit, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
Running migrations:
Applying contenttypes.0001_initial... OK
...
$ python manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.
$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
January 28, 2025 - 10:00:00
Django version 5.0, using settings 'mywagtailproject.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Project Structure
mywagtailproject/
├── manage.py
├── requirements.txt
├── mywagtailproject/ # Project configuration
│ ├── settings/
│ │ ├── base.py # Base settings
│ │ ├── dev.py # Development settings
│ │ └── production.py # Production settings
│ ├── urls.py # Main URL config
│ └── wsgi.py
├── home/ # Default app (home page)
│ ├── models.py # Page models
│ ├── views.py
│ └── templates/
│ └── home/
│ └── home_page.html
├── search/ # Search functionality
│ ├── views.py
│ └── templates/
└── static/ # Static files (CSS, JS, images)
├── css/
├── js/
└── wagtailimages/
Creating Custom Page Models
# home/models.py
from django.db import models
from wagtail.models import Page
from wagtail.fields import RichTextField, StreamField
from wagtail.admin.panels import FieldPanel, MultiFieldPanel, InlinePanel
from wagtail.search import index
from wagtail import blocks
class BlogPage(Page):
"""A custom blog page model"""
# Template for this page type
template = "home/blog_page.html"
# Page fields
date = models.DateField("Post date")
author = models.CharField(max_length=100, default="Admin")
intro = RichTextField(blank=True, features=['bold', 'italic', 'link'])
body = RichTextField()
# Image field
hero_image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Hero image for the blog post"
)
# SEO fields
search_description = models.TextField(blank=True)
# Panels - define how fields appear in admin
content_panels = Page.content_panels + [
FieldPanel('date'),
FieldPanel('author'),
FieldPanel('hero_image'),
FieldPanel('intro'),
FieldPanel('body'),
]
# Promote panels (for SEO, social media)
promote_panels = Page.promote_panels + [
FieldPanel('search_description'),
]
# Search configuration
search_fields = Page.search_fields + [
index.SearchField('intro'),
index.SearchField('body'),
index.FilterField('date'),
]
def get_context(self, request):
"""Add extra variables to template context"""
context = super().get_context(request)
context['related_posts'] = BlogPage.objects.live().public()\
.exclude(id=self.id)[:3]
return context
class Meta:
verbose_name = "Blog Post"
verbose_name_plural = "Blog Posts"
class HomePage(Page):
"""Home page model"""
template = "home/home_page.html"
# Hero section
hero_title = models.CharField(max_length=100, default="Welcome")
hero_subtitle = models.CharField(max_length=200, blank=True)
hero_image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
# Featured content
featured_title = models.CharField(max_length=100, blank=True)
featured_intro = RichTextField(blank=True)
# Call to action
cta_title = models.CharField(max_length=100, blank=True)
cta_button_text = models.CharField(max_length=50, default="Learn More")
cta_button_link = models.ForeignKey(
'wagtailcore.Page',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
content_panels = Page.content_panels + [
MultiFieldPanel([
FieldPanel('hero_title'),
FieldPanel('hero_subtitle'),
FieldPanel('hero_image'),
], heading="Hero Section"),
MultiFieldPanel([
FieldPanel('featured_title'),
FieldPanel('featured_intro'),
], heading="Featured Content"),
MultiFieldPanel([
FieldPanel('cta_title'),
FieldPanel('cta_button_text'),
FieldPanel('cta_button_link'),
], heading="Call to Action"),
]
def get_context(self, request):
context = super().get_context(request)
context['blog_posts'] = BlogPage.objects.live().public().order_by('-date')[:6]
return context
StreamField: Flexible Content Blocks
# home/models.py - StreamField example
from wagtail.blocks import (
CharBlock, RichTextBlock, ImageChooserBlock, StructBlock,
ListBlock, PageChooserBlock, ChoiceBlock
)
class CustomImageBlock(StructBlock):
"""Image block with caption"""
image = ImageChooserBlock(required=True)
caption = CharBlock(required=False)
alignment = ChoiceBlock(choices=[
('left', 'Left'),
('center', 'Center'),
('right', 'Right'),
], default='center')
class Meta:
icon = 'image'
template = 'blocks/image_block.html'
label = 'Image with Caption'
class CodeBlock(StructBlock):
"""Code snippet block"""
language = ChoiceBlock(choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('html', 'HTML/CSS'),
('bash', 'Bash'),
('yaml', 'YAML'),
('json', 'JSON'),
], default='python')
code = CharBlock(widget=forms.Textarea, required=True)
caption = CharBlock(required=False)
class Meta:
icon = 'code'
template = 'blocks/code_block.html'
label = 'Code Snippet'
class CardBlock(StructBlock):
"""Card component for displaying content"""
title = CharBlock(required=True)
description = RichTextBlock(required=True)
image = ImageChooserBlock(required=False)
link = PageChooserBlock(required=False)
link_text = CharBlock(default='Read More', required=False)
class Meta:
icon = 'card'
template = 'blocks/card_block.html'
label = 'Card'
class BlogPage(Page):
# ... other fields ...
# StreamField for flexible content
body = StreamField([
('heading', blocks.CharBlock(form_classname="full title")),
('paragraph', blocks.RichTextBlock()),
('image', CustomImageBlock()),
('code', CodeBlock()),
('quote', blocks.BlockQuoteBlock()),
('cards', ListBlock(CardBlock(), icon='stack-overflow')),
('video', blocks.URLBlock(help_text="YouTube or Vimeo URL")),
('cta', StructBlock([
('title', CharBlock()),
('text', CharBlock()),
('button_text', CharBlock()),
('button_link', PageChooserBlock()),
])),
], use_json_field=True, blank=True)
content_panels = Page.content_panels + [
# ... other panels ...
FieldPanel('body'), # StreamField appears as a rich editor
]
<!-- templates/blocks/image_block.html -->
{% load wagtailimages_tags %}
<div class="image-block text-{{ self.alignment }}">
{% image self.image width-800 class="img-fluid rounded" %}
{% if self.caption %}
<p class="caption text-muted text-center mt-2">{{ self.caption }}</p>
{% endif %}
</div>
<!-- templates/blocks/code_block.html -->
<div class="code-block mb-4">
<div class="code-header">
<span class="code-language">{{ self.language|upper }}</span>
{% if self.caption %}
<span class="code-caption">{{ self.caption }}</span>
{% endif %}
</div>
<pre><code class="language-{{ self.language }}">{{ self.code }}</code></pre>
</div>
<!-- templates/blocks/card_block.html -->
<div class="card h-100">
{% if self.image %}
{% image self.image fill-400x300 class="card-img-top" %}
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ self.title }}</h5>
<div class="card-text">{{ self.description|richtext }}</div>
{% if self.link %}
<a href="{% pageurl self.link %}" class="btn btn-primary">
{{ self.link_text }}
</a>
{% endif %}
</div>
</div>
Wagtail Snippets (Reusable Content)
# snippets/models.py
from django.db import models
from wagtail.snippets.models import register_snippet
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.images.models import Image
@register_snippet
class TeamMember(models.Model):
"""Reusable team member component"""
name = models.CharField(max_length=100)
role = models.CharField(max_length=100)
bio = models.TextField()
photo = models.ForeignKey(
Image,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
email = models.EmailField(blank=True)
linkedin_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
twitter_handle = models.CharField(max_length=50, blank=True)
order = models.IntegerField(default=0)
panels = [
MultiFieldPanel([
FieldPanel('name'),
FieldPanel('role'),
FieldPanel('photo'),
FieldPanel('bio'),
], heading="Personal Information"),
MultiFieldPanel([
FieldPanel('email'),
FieldPanel('linkedin_url'),
FieldPanel('github_url'),
FieldPanel('twitter_handle'),
], heading="Contact Information"),
FieldPanel('order'),
]
class Meta:
ordering = ['order', 'name']
def __str__(self):
return self.name
@register_snippet
class Testimonial(models.Model):
"""Customer testimonial snippet"""
name = models.CharField(max_length=100)
company = models.CharField(max_length=100, blank=True)
text = models.TextField()
rating = models.IntegerField(default=5, choices=[(i, i) for i in range(1, 6)])
date = models.DateField(auto_now_add=True)
is_featured = models.BooleanField(default=False)
panels = [
FieldPanel('name'),
FieldPanel('company'),
FieldPanel('text'),
FieldPanel('rating'),
FieldPanel('is_featured'),
]
def __str__(self):
return f"{self.name} - {self.rating}⭐"
@register_snippet
class FooterLink(models.Model):
"""Footer navigation links (editable by admin)"""
title = models.CharField(max_length=100)
link = models.URLField(blank=True)
page = models.ForeignKey(
'wagtailcore.Page',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
order = models.IntegerField(default=0)
def get_url(self):
if self.page:
return self.page.url
return self.link
def __str__(self):
return self.title
Image Handling
# home/models.py - Image-rich models
from wagtail.images.models import Image
from wagtail.images.api.fields import ImageRenditionField
class GalleryPage(Page):
"""Image gallery page"""
gallery_title = models.CharField(max_length=100)
# Multiple images via InlinePanel
content_panels = Page.content_panels + [
FieldPanel('gallery_title'),
InlinePanel('gallery_images', label="Gallery Images"),
]
class GalleryImage(Orderable):
"""Individual gallery image"""
page = ParentalKey(GalleryPage, on_delete=models.CASCADE, related_name='gallery_images')
image = models.ForeignKey(
Image,
on_delete=models.CASCADE,
related_name='+'
)
caption = models.CharField(max_length=250, blank=True)
panels = [
FieldPanel('image'),
FieldPanel('caption'),
]
# Template usage
# {% load wagtailimages_tags %}
#
# {% image page.hero_image fill-1200x400 class="hero-image" %}
# {% image page.hero_image width-800 %}
# {% image page.hero_image height-400 %}
# {% image page.hero_image max-800x600 %}
# {% image page.hero_image original %}
#
# {% for gallery_image in page.gallery_images.all %}
# {% image gallery_image.image fill-400x300 %}
# <p>{{ gallery_image.caption }}</p>
# {% endfor %}
4. Complete Project: Blog with Categories and Tags
# blog/models.py
from django.db import models
from wagtail.models import Page, Orderable
from wagtail.fields import StreamField, RichTextField
from wagtail.admin.panels import FieldPanel, MultiFieldPanel, InlinePanel
from wagtail.search import index
from wagtail.snippets.models import register_snippet
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
# Blog Category Snippet
@register_snippet
class BlogCategory(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
icon = models.CharField(max_length=50, blank=True, help_text="FontAwesome icon class")
panels = [
FieldPanel('name'),
FieldPanel('slug'),
FieldPanel('icon'),
]
def __str__(self):
return self.name
class Meta:
verbose_name_plural = "Blog Categories"
# Blog Tag (simple free tagging)
@register_snippet
class BlogTag(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
class BlogIndexPage(Page):
"""Listing page for blog posts"""
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [
FieldPanel('intro'),
]
def get_context(self, request):
context = super().get_context(request)
# Get all blog posts
blog_posts = BlogPage.objects.live().public().order_by('-date')
# Filter by category
category_slug = request.GET.get('category')
if category_slug:
blog_posts = blog_posts.filter(categories__slug=category_slug)
context['current_category'] = BlogCategory.objects.get(slug=category_slug)
# Pagination
page = request.GET.get('page', 1)
paginator = Paginator(blog_posts, 10)
context['blog_posts'] = paginator.get_page(page)
# All categories for sidebar
context['categories'] = BlogCategory.objects.all()
return context
class BlogPage(Page):
"""Individual blog post"""
template = "blog/blog_page.html"
# Basic info
date = models.DateField("Post date")
author = models.CharField(max_length=100)
intro = models.TextField(max_length=500, help_text="Short introduction")
# Content
body = StreamField([
('heading', blocks.CharBlock(form_classname="full title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageBlock()),
('code', CodeBlock()),
('quote', blocks.BlockQuoteBlock()),
], use_json_field=True)
# Relationships
categories = models.ManyToManyField(BlogCategory, blank=True)
tags = models.ManyToManyField(BlogTag, blank=True)
# Image
hero_image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
content_panels = Page.content_panels + [
MultiFieldPanel([
FieldPanel('date'),
FieldPanel('author'),
FieldPanel('hero_image'),
], heading="Post Meta"),
FieldPanel('intro'),
FieldPanel('body'),
MultiFieldPanel([
FieldPanel('categories', widget=forms.CheckboxSelectMultiple),
FieldPanel('tags', widget=forms.CheckboxSelectMultiple),
], heading="Taxonomies"),
]
promote_panels = Page.promote_panels + [
MultiFieldPanel([
FieldPanel('seo_title'),
FieldPanel('search_description'),
], heading="SEO"),
]
search_fields = Page.search_fields + [
index.SearchField('intro'),
index.SearchField('body'),
index.FilterField('date'),
]
def get_context(self, request):
context = super().get_context(request)
context['related_posts'] = BlogPage.objects.live().public().filter(
categories__in=self.categories.all()
).exclude(id=self.id).distinct()[:3]
return context
class Meta:
verbose_name = "Blog Post"
verbose_name_plural = "Blog Posts"
<!-- templates/blog/blog_page.html -->
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% block content %}
<article class="blog-post">
<!-- Hero image -->
{% if page.hero_image %}
{% image page.hero_image width-1200 class="hero-image" %}
{% endif %}
<!-- Post header -->
<header class="post-header">
<h1>{{ page.title }}</h1>
<div class="post-meta">
<span class="date">{{ page.date|date:"F j, Y" }}</span>
<span class="author">By {{ page.author }}</span>
</div>
<!-- Categories -->
{% if page.categories.all %}
<div class="post-categories">
<strong>Categories:</strong>
{% for category in page.categories.all %}
<a href="{% slugurl 'blog' %}?category={{ category.slug }}" class="category-badge">
{% if category.icon %}<i class="{{ category.icon }}"></i>{% endif %}
{{ category.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Tags -->
{% if page.tags.all %}
<div class="post-tags">
<strong>Tags:</strong>
{% for tag in page.tags.all %}
<a href="{% slugurl 'blog' %}?tag={{ tag.slug }}" class="tag">
#{{ tag.name }}
</a>
{% endfor %}
</div>
{% endif %}
</header>
<!-- Introduction -->
<div class="post-intro lead">
{{ page.intro|richtext }}
</div>
<!-- Body content with StreamField -->
<div class="post-body">
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</div>
</article>
<!-- Related posts -->
{% if related_posts %}
<section class="related-posts">
<h3>Related Posts</h3>
<div class="row">
{% for post in related_posts %}
<div class="col-md-4">
<div class="card">
{% if post.hero_image %}
{% image post.hero_image fill-300x200 class="card-img-top" %}
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ post.title }}</h5>
<a href="{% pageurl post %}" class="btn btn-primary">Read More</a>
</div>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Comments section -->
<div class="comments-section">
<h3>Comments</h3>
<div id="disqus_thread"></div>
</div>
{% endblock %}
5. Customizing the Wagtail Admin
# wagtail_hooks.py (in your app directory)
from wagtail import hooks
from wagtail.admin.menu import MenuItem
from django.urls import path, reverse
from django.shortcuts import render
# Add custom admin menu item
@hooks.register('register_admin_menu_item')
def register_custom_menu():
return MenuItem('Analytics', '/admin/analytics/', icon_name='site', order=10000)
# Add custom CSS to admin
@hooks.register('insert_global_admin_css')
def global_admin_css():
return format_html('<link rel="stylesheet" href="/static/css/admin-custom.css">')
# Add custom JavaScript to admin
@hooks.register('insert_editor_js')
def editor_js():
return format_html(
'<script src="/static/js/admin-custom.js"></script>'
)
# Custom admin view
@hooks.register('register_admin_urls')
def register_custom_admin_urls():
return [
path('analytics/', analytics_view, name='analytics'),
]
def analytics_view(request):
# Custom analytics dashboard
return render(request, 'admin/analytics.html')
6. Wagtail API
# settings.py
INSTALLED_APPS = [
# ...
'wagtail.api.v2',
'rest_framework',
]
# Configure API
WAGTAILAPI_BASE_URL = 'https://example.com'
# myproject/api.py
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
# Create API router
api_router = WagtailAPIRouter('wagtailapi')
# Add endpoints
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
api_router.register_endpoint('documents', DocumentsAPIViewSet)
# urls.py
urlpatterns = [
path('api/v2/', api_router.urls),
# ...
]
# API usage:
# GET /api/v2/pages/
# GET /api/v2/pages/?type=blog.BlogPage
# GET /api/v2/pages/5/
# GET /api/v2/images/
# GET /api/v2/documents/
7. Common Errors & Solutions
8. Summary Checklist
9. Next Steps
Advanced Wagtail topics to learn next:
- Wagtail Workflows: Content moderation and approval processes
- Wagtail Headless: Using Wagtail as a headless CMS with Next.js or Vue
- Wagtail Forms: Building custom forms with submissions
- Wagtail Embeds: oEmbed support for YouTube, Twitter, etc.
- Wagtail SEO: Advanced SEO features and sitemaps
Practice resources:
- Wagtail Official Documentation
- Wagtail Learn - Free tutorials
- Wagtail GitHub - Source code and examples
- Wagtail CMS Community
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment