Wagtail Python Tutorial

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 BlogIndexPage listing posts with tag filtering and pagination
  • A BlogPage with a StreamField body supporting headings, rich text, images, and code blocks
  • Tagging via modelcluster with 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.

WAGTAIL PAGE TREE Root HomePage BlogIndexPage /blog/ BlogPage /blog/first-post/ BlogPage /blog/second-post/
Wagtail page hierarchy: BlogIndexPage lives under HomePage, and each BlogPage is a child of the index. URLs derive automatically from the slug chain.

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 %} &middot;
      {% 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 }}">&larr; 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 &rarr;</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.