Build a Blog with Wagtail: Complete Beginner Tutorial
Wagtail is the fastest path from a Django project to a fully featured CMS. This tutorial walks
you through building a real blog — page models, StreamField body, templates, tag filtering,
pagination, RSS feed, and SEO fields — from a fresh pip install to published posts.
1. What You'll Build
By the end of this tutorial you will have a working Wagtail blog with:
- A
BlogIndexPagelisting posts with tag filtering and pagination - A
BlogPagewith a StreamField body supporting headings, rich text, images, and code blocks - Tagging via
modelclusterwith filtered listing URLs - An RSS/Atom feed at
/blog/feed/ - Proper Wagtail SEO fields (meta title, description, OG image) on every post
The code is compatible with Wagtail 6.x and Django 5.x. Everything shown here is production-ready.
2. Install Wagtail & Start a Project
pip install wagtail
wagtail start mysite
cd mysite
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Wagtail's start command scaffolds a project with a working home app,
a search app, and a pre-configured settings package. Open
http://127.0.0.1:8000/cms/ and log in with the superuser you just created.
Now create the blog app:
python manage.py startapp blog
Add it to INSTALLED_APPS in mysite/settings/base.py:
INSTALLED_APPS = [
# ...
'blog',
]
3. Blog Models
Wagtail pages are Django models subclassing wagtail.models.Page. We need two: a
listing index page and individual post pages.
blog/models.py
from django.db import models
from django import forms
from modelcluster.fields import ParentalKey, ParentalManyToManyField
from modelcluster.contrib.taggit import ClusterTaggableManager
from taggit.models import TaggedItemBase
from wagtail.models import Page, Orderable
from wagtail.fields import RichTextField, StreamField
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.search import index
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
from blog.blocks import BlogStreamBlock
class BlogIndexPage(RoutablePageMixin, Page):
"""Listing page — shows all blog posts, supports tag filtering."""
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [
FieldPanel('intro'),
]
subpage_types = ['blog.BlogPage']
@path('')
def index_view(self, request):
posts = BlogPage.objects.live().descendant_of(self).order_by('-date')
tag = request.GET.get('tag')
if tag:
posts = posts.filter(tags__slug=tag)
return self.render(request, context_overrides={
'posts': posts,
'active_tag': tag,
})
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context['posts'] = (
BlogPage.objects.live().descendant_of(self).order_by('-date')
)
return context
class BlogPageTag(TaggedItemBase):
content_object = ParentalKey(
'BlogPage',
related_name='tagged_items',
on_delete=models.CASCADE,
)
class BlogPage(Page):
"""Individual blog post."""
date = models.DateField('Post date')
intro = models.CharField(max_length=250)
body = StreamField('blog.BlogStreamBlock', use_json_field=True)
tags = ClusterTaggableManager(through=BlogPageTag, blank=True)
category = models.CharField(max_length=80, blank=True)
search_fields = Page.search_fields + [
index.SearchField('intro', boost=4),
index.SearchField('body', boost=1),
]
content_panels = Page.content_panels + [
MultiFieldPanel([
FieldPanel('date'),
FieldPanel('tags'),
FieldPanel('category'),
], heading='Post metadata'),
FieldPanel('intro'),
FieldPanel('body'),
]
parent_page_types = ['blog.BlogIndexPage']
subpage_types = []
Key points: RoutablePageMixin on the index page lets us add a /tag/slug/
sub-route without a separate Django URL entry. ClusterTaggableManager integrates
django-taggit with Wagtail's page-revision system through modelcluster.
4. StreamField Body
Define the block types the body can contain in a separate blocks.py file — this
keeps models.py clean and makes blocks reusable across page types.
blog/blocks.py
from wagtail.blocks import (
CharBlock, ChoiceBlock, RichTextBlock, StreamBlock,
StructBlock, TextBlock,
)
from wagtail.embeds.blocks import EmbedBlock
from wagtail.images.blocks import ImageChooserBlock
class ImageBlock(StructBlock):
image = ImageChooserBlock()
caption = CharBlock(required=False)
alt = CharBlock(required=False, help_text='Alt text (leave blank to use image title)')
class Meta:
icon = 'image'
template = 'blog/blocks/image_block.html'
class HeadingBlock(StructBlock):
heading_text = CharBlock(classname='title', required=True)
size = ChoiceBlock(choices=[
('', 'Select heading size'),
('h2', 'H2'),
('h3', 'H3'),
('h4', 'H4'),
], blank=True, required=False)
class Meta:
icon = 'title'
template = 'blog/blocks/heading_block.html'
class CodeBlock(StructBlock):
language = ChoiceBlock(choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('bash', 'Bash / Shell'),
('html', 'HTML'),
('css', 'CSS'),
('sql', 'SQL'),
('json', 'JSON'),
])
code = TextBlock()
class Meta:
icon = 'code'
template = 'blog/blocks/code_block.html'
class BlogStreamBlock(StreamBlock):
heading = HeadingBlock()
paragraph = RichTextBlock(features=[
'h2', 'h3', 'bold', 'italic', 'link', 'ol', 'ul', 'blockquote', 'image',
])
image = ImageBlock()
code = CodeBlock()
embed = EmbedBlock(
help_text='YouTube, Vimeo, Twitter URLs are supported',
icon='media',
)
class Meta:
block_counts = {
'heading': {'min_num': 0},
}
Each StructBlock subclass gets its own template. Here is image_block.html:
{# blog/templates/blog/blocks/image_block.html #}
{% load wagtailimages_tags %}
<figure class="post-figure">
{% image value.image width-900 as img %}
<img src="{{ img.url }}"
width="{{ img.width }}"
height="{{ img.height }}"
alt="{% if value.alt %}{{ value.alt }}{% else %}{{ value.image.title }}{% endif %}"
loading="lazy">
{% if value.caption %}
<figcaption>{{ value.caption }}</figcaption>
{% endif %}
</figure>
5. Migrations & Admin Setup
python manage.py makemigrations blog
python manage.py migrate
In the Wagtail admin, navigate to Pages → Root → Home and add a child page of type Blog Index Page. Publish it. Now add a Blog Page as a child of the index. You should see your StreamField block picker in the Body section.
6. Templates
Wagtail resolves templates by converting the model's app label and class name to a path. For
blog.BlogIndexPage it looks for blog/templates/blog/blog_index_page.html.
blog_index_page.html
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% block content %}
<section class="blog-index">
<h1>{{ page.title }}</h1>
{% if page.intro %}
<div class="intro">{{ page.intro|richtext }}</div>
{% endif %}
{% if active_tag %}
<p class="tag-filter">Posts tagged <strong>{{ active_tag }}</strong>
<a href="{{ page.url }}">clear</a></p>
{% endif %}
<div class="post-list">
{% for post in posts %}
<article class="post-card">
<h2><a href="{% pageurl post %}">{{ post.title }}</a></h2>
<p class="meta">{{ post.date }} — {{ post.intro }}</p>
{% if post.tags.all %}
<ul class="tags">
{% for tag in post.tags.all %}
<li><a href="{{ page.url }}?tag={{ tag.slug }}">{{ tag }}</a></li>
{% endfor %}
</ul>
{% endif %}
</article>
{% empty %}
<p>No posts yet.</p>
{% endfor %}
</div>
</section>
{% endblock %}
blog_page.html
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% block content %}
<article class="post">
<header class="post-header">
<h1>{{ page.title }}</h1>
<p class="meta">{{ page.date }}{% if page.tags.all %} ·
{% for tag in page.tags.all %}
<a href="{{ page.get_parent.url }}?tag={{ tag.slug }}">{{ tag }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endif %}</p>
{% if page.intro %}<p class="intro">{{ page.intro }}</p>{% endif %}
</header>
<div class="post-body">
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</div>
</article>
{% endblock %}
{% include_block block %} renders each StreamField block using its declared template.
This is cleaner than a manual {% if block.block_type == "..." %} chain.
7. Tag Filtering
The RoutablePageMixin on BlogIndexPage lets you add clean
/blog/tag/django/ URLs instead of query string filtering:
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
class BlogIndexPage(RoutablePageMixin, Page):
@path('')
def index_view(self, request):
posts = self._get_posts()
return self.render(request, context_overrides={'posts': posts})
@path('tag/<slug:tag>/')
def tag_view(self, request, tag):
posts = self._get_posts().filter(tags__slug=tag)
return self.render(request, context_overrides={
'posts': posts,
'active_tag': tag,
})
def _get_posts(self):
return (
BlogPage.objects.live()
.descendant_of(self)
.order_by('-date')
.select_related('owner')
.prefetch_related('tags')
)
Make sure to add 'wagtail.contrib.routable_page' to INSTALLED_APPS.
The @path('') decorator handles the bare index URL; @path('tag/<slug:tag>/')
handles tag filtering. Both use self.render() which correctly resolves the index
page's template.
8. Pagination
Add Django's Paginator inside the view method:
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
@path('')
def index_view(self, request):
all_posts = self._get_posts()
paginator = Paginator(all_posts, per_page=10)
page_num = request.GET.get('page')
try:
posts = paginator.page(page_num)
except PageNotAnInteger:
posts = paginator.page(1)
except EmptyPage:
posts = paginator.page(paginator.num_pages)
return self.render(request, context_overrides={
'posts': posts,
'paginator': paginator,
})
In the template, add navigation:
{% if posts.has_other_pages %}
<nav class="pagination">
{% if posts.has_previous %}
<a href="?page={{ posts.previous_page_number }}">← Newer</a>
{% endif %}
<span>Page {{ posts.number }} of {{ posts.paginator.num_pages }}</span>
{% if posts.has_next %}
<a href="?page={{ posts.next_page_number }}">Older →</a>
{% endif %}
</nav>
{% endif %}
9. RSS Feed
Django's Feed class works alongside Wagtail pages. Create blog/feeds.py:
from django.contrib.syndication.views import Feed
from django.urls import reverse
from .models import BlogPage
class LatestPostsFeed(Feed):
title = 'WebbyFox Blog'
link = '/blog/'
description = 'Latest posts on Django, Wagtail, and Python.'
def items(self):
return BlogPage.objects.live().order_by('-date')[:20]
def item_title(self, item):
return item.title
def item_description(self, item):
return item.intro
def item_pubdate(self, item):
import datetime
return datetime.datetime.combine(item.date, datetime.time.min)
Wire it up in mysite/urls.py:
from blog.feeds import LatestPostsFeed
urlpatterns = [
# ... existing entries ...
path('blog/feed/', LatestPostsFeed(), name='blog_feed'),
path('', include(wagtail_urls)), # must remain last
]
10. SEO Fields & Sitemap
Every Wagtail Page inherits SEO fields from the Promote tab in
the admin: slug, SEO title, search description, and OG image. You do not need to add these
yourself — just tell editors to fill them in.
For a sitemap, install 'wagtail.contrib.sitemaps' and add to your URLconf:
from wagtail.contrib.sitemaps.views import sitemap
urlpatterns = [
# ...
path('sitemap.xml', sitemap),
path('', include(wagtail_urls)),
]
The Wagtail sitemap view automatically includes all live pages. For per-page changefreq
and priority, subclass Sitemap and override get_urls().
You now have a production-ready Wagtail blog. Next steps: add image renditions for Open Graph previews, set up Elasticsearch for search, or explore custom StreamField blocks for richer content types.