Wagtail's built-in CharBlock, RichTextBlock, and ImageChooserBlock cover the basics. But real-world sites need structured components: pricing tiers, accordion FAQs, multi-column sections, and carousels. This lesson dives into custom blocks — StructBlock for composite components, ListBlock for repeatable items, StreamBlock for flexible sections, nested blocks, custom validation with clean(), and per-block templates. You'll finish with a production-ready reusable block library.

Wagtail Custom Blocks & Advanced StreamField: Building Reusable Content Components

1. Learning Objectives

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

2. Why This Matters (Real-World Scenario)

Your marketing team has designed a rich landing page with pricing tiers, accordion FAQs, testimonial carousels, call-to-action banners, and multi-column content sections — each with its own layout, styling, and validation rules. A standard RichTextBlock simply can't handle this. Without custom blocks, editors would need to cram everything into one blob of HTML, losing structure and opening the door to broken layouts. By defining custom block types — each with validated fields, dedicated templates, and meaningful admin previews — you give content editors a toolbox of components they can assemble freely while keeping the front-end output pixel-perfect and maintainable.

3. Core Concepts

Wagtail's StreamField power comes from its block system. Beyond the built-in blocks (CharBlock, RichTextBlock, ImageChooserBlock, etc.), Wagtail lets you define entirely new block types by composing existing ones.

3.1 StructBlock — Composing Fields into a Component

A StructBlock bundles multiple sub-fields into a single reusable component. Each sub-field becomes a property accessible via value.field_name in templates. Think of it as a Django Form rendered inside a StreamField.

from wagtail import blocks

class FAQItemBlock(blocks.StructBlock):
    question = blocks.CharBlock(required=True, max_length=200)
    answer = blocks.RichTextBlock(required=True, features=['bold', 'italic', 'link'])

    class Meta:
        icon = 'help'
        label = 'FAQ Item'
        label_format = "Q: {question}"  # Dynamic label in admin
home/blocks.py — A simple FAQ item as a StructBlock

Once defined, register the block in your StreamField alongside built-in types:

from wagtail.fields import StreamField
from wagtail.models import Page
from .blocks import FAQItemBlock

class FAQPage(Page):
    body = StreamField([
        ('heading', blocks.CharBlock()),
        ('faq_items', blocks.ListBlock(FAQItemBlock())),
    ], use_json_field=True)

    content_panels = Page.content_panels + [
        FieldPanel('body'),
    ]
Registering a custom StructBlock in a page model

The label_format meta option (Wagtail 4.1+) shows a dynamic preview in the admin stream, so editors see "Q: What is your return policy?" instead of the generic "FAQ Item" label.

3.2 ListBlock — Repeatable Items

ListBlock wraps any block type into a sortable, repeatable list. Use it for feature lists, team members, testimonials, or any multi-instance content.

from wagtail import blocks

class PricingTierBlock(blocks.StructBlock):
    tier_name = blocks.CharBlock(max_length=100)
    price = blocks.CharBlock(max_length=50)
    features = blocks.ListBlock(blocks.CharBlock(label='Feature'))
    highlighted = blocks.BooleanBlock(required=False, default=False)

    class Meta:
        icon = 'pick'
        label = 'Pricing Tier'
        template = 'blocks/pricing_tier.html'
A pricing tier with a repeatable features list inside a StructBlock

3.3 StreamBlock — Flexible Content Sections

While the top-level StreamField already mixes block types, an inner StreamBlock lets you define a constrained palette of blocks for a single content section. This is ideal for hero areas, sidebars, or inline content galleries.

class ContentSectionBlock(blocks.StreamBlock):
    heading = blocks.CharBlock()
    paragraph = blocks.RichTextBlock()
    image = blocks.ImageChooserBlock()
    quote = blocks.BlockQuoteBlock()
    code_snippet = blocks.StructBlock([
        ('language', blocks.ChoiceBlock(choices=[
            ('python', 'Python'),
            ('javascript', 'JavaScript'),
            ('bash', 'Bash'),
        ])),
        ('code', blocks.TextBlock()),
    ])
    cta_button = blocks.StructBlock([
        ('text', blocks.CharBlock()),
        ('url', blocks.URLBlock()),
        ('style', blocks.ChoiceBlock(choices=[
            ('primary', 'Primary'),
            ('secondary', 'Secondary'),
        ])),
    ])

    class Meta:
        label = 'Content Section'
        block_counts = {  # Enforce at least one paragraph
            'paragraph': {'min_num': 1, 'max_num': 10},
            'heading': {'min_num': 1},
        }
A StreamBlock that defines exactly what can appear inside a content section

The block_counts meta option enforces minimum/maximum occurrences of each block type — a powerful guardrail for editorial workflows.

4. Hands-On Practice

Let's build a reusable block library step by step inside a Wagtail project.

Step 1: Project Scaffold

Wagtail conventionally stores block definitions in a blocks.py file inside each Django app. You can also create a blocks/ package for larger projects.

wagtail_project/
├── home/
│   ├── blocks.py       # Custom block definitions
│   └── templates/
│       └── blocks/      # Per-block templates (recommended)
├── blog/
│   ├── blocks.py        # Or co-locate blocks with their app
│   └── models.py        # StreamField declarations
└── mysite/
    └── settings.py
Recommended project layout for custom blocks

Step 2: Define a Reusable Button Block

Start with a simple, widely reusable component:

# Reusable blocks library: myproject/blocks.py

from wagtail import blocks

# ── Shared choices ──
BACKGROUND_CHOICES = [
    ('white', 'White'),
    ('light', 'Light Gray'),
    ('dark', 'Dark'),
    ('accent', 'Accent Color'),
]

# ── Base Components ──
class ButtonBlock(blocks.StructBlock):
    text = blocks.CharBlock(max_length=60)
    url = blocks.URLBlock()
    style = blocks.ChoiceBlock(choices=[
        ('primary', 'Primary'),
        ('outline', 'Outline'),
        ('ghost', 'Ghost'),
    ], default='primary')

    class Meta:
        icon = 'link'
        label = 'Button'

class ImageWithCaptionBlock(blocks.StructBlock):
    image = blocks.ImageChooserBlock()
    caption = blocks.CharBlock(required=False, max_length=200)
    width = blocks.ChoiceBlock(choices=[
        ('full', 'Full Width'),
        ('half', 'Half Width'),
    ], default='full')

    class Meta:
        icon = 'image'
        label = 'Image with Caption'
blocks.py — Shared block library with a ButtonBlock and ImageWithCaptionBlock

Step 3: Add Block Rendering Templates

Custom blocks can ship their own templates. Create home/templates/blocks/pricing_tier.html:

{% load wagtailcore_tags %}

<div class="pricing-card {% if value.highlighted %}pricing-card--featured{% endif %}">
    <h3 class="pricing-card__name">{{ value.tier_name }}</h3>
    <p class="pricing-card__price">{{ value.price }}</p>
    <ul class="pricing-card__features">
        {% for feature in value.features %}
            <li>{{ feature }}</li>
        {% endfor %}
    </ul>
</div>
Per-block template rendering

When you include template = 'blocks/pricing_tier.html' in the block's Meta, Wagtail automatically renders each instance through that template when you use {% include_block %} in your page template. No manual if-else chains needed.

Step 4: Build Nested Blocks

Blocks can contain blocks, which can contain blocks — to any depth. Here's an advanced section with configurable column layouts:

class AdvancedSectionBlock(blocks.StructBlock):
    section_title = blocks.CharBlock()
    layout = blocks.ChoiceBlock(choices=[
        ('single', 'Single Column'),
        ('two-col', 'Two Column'),
        ('three-col', 'Three Column'),
    ], default='single')
    columns = blocks.StreamBlock([
        ('column', blocks.StreamBlock([
            ('text', blocks.RichTextBlock()),
            ('image', blocks.ImageChooserBlock()),
            ('video', blocks.URLBlock(label='Video URL')),
        ])),
    ], min_num=1, max_num=3)
    background_color = blocks.ChoiceBlock(choices=[
        ('white', 'White'),
        ('gray', 'Light Gray'),
        ('dark', 'Dark'),
    ], default='white')

    class Meta:
        icon = 'placeholder'
        label = 'Advanced Section'
        template = 'blocks/advanced_section.html'
Deeply nested blocks for a flexible section layout
kubectl get pods -w
$ python manage.py shell
>>> from home.blocks import AdvancedSectionBlock
>>> block = AdvancedSectionBlock()
>>> block.clean({"layout": "two-col", "columns": [{"column": [{"type": "text", "value": "<p>Left column</p>"}]}]})
{'layout': 'two-col', 'columns': [BlockValue({'column': [BlockValue({'text': '<p>Left column</p>'})]})]}
Testing nested block validation in the Django shell

Step 5: Add Custom Validation

Override the clean() method on any block to add cross-field validation:

from wagtail import blocks
from django.core.exceptions import ValidationError

class AccordionItemBlock(blocks.StructBlock):
    title = blocks.CharBlock(max_length=150)
    content = blocks.RichTextBlock()

    class Meta:
        icon = 'list-ul'
        label = 'Accordion Item'

    def clean(self, value):
        result = super().clean(value)
        # If title AND content are both empty, raise error
        if not result.get('title') and not result.get('content'):
            raise ValidationError('Accordion items must have either a title or content.')
        return result
Custom block validation — reject items missing both title and content

Step 6: Register Blocks in a Page Model

Now wire everything together:

from wagtail.fields import StreamField
from wagtail.models import Page
from wagtail import blocks
from .blocks import (
    ButtonBlock, ImageWithCaptionBlock,
    ContentSectionBlock, CTABlock
)

class ContentPage(Page):
    body = StreamField([
        ('heading', blocks.CharBlock(form_classname='full title')),
        ('paragraph', blocks.RichTextBlock()),
        ('button', ButtonBlock()),
        ('image_with_caption', ImageWithCaptionBlock()),
        ('content_section', ContentSectionBlock()),
        ('cta', CTABlock()),
    ], use_json_field=True, block_counts={
        'heading': {'max_num': 10},
    })

    content_panels = Page.content_panels + [
        FieldPanel('body'),
    ]
A ContentPage using the full custom block library

5. Common Errors & Solutions

ErrorCauseFix
AttributeError: 'BoundBlock' object has no attribute 'field_name'Accessing StructBlock values with dot notation before calling {% include_block %}Use {% include_block block %} which passes the StructValue object with attribute access
TypeError: Object of type StructValue is not JSON serializableTrying to dump StreamField data without use_json_field=TrueSet use_json_field=True on all StreamField definitions (Wagtail 4+)
Block silently not rendering on the pageMissing template for a custom block, or the template path is wrongVerify the template path relative to your app's templates/ directory; add from django.conf import settings; print(settings.TEMPLATES) to check template dirs
ValidationError in admin but not in shellThe clean() method modifies an immutable StructValue in-place instead of returning a new oneAlways call super().clean(value) first and work with the result dict
ListBlock items cannot be reordered in adminMissing Wagtail admin JavaScript or using a third-party package that doesn't include sortable widget assetsEnsure wagtail.contrib.forms is not overriding the admin assets; check browser console for JS errors

6. Summary Checklist

7. Practice Exercise

Build a Testimonial Carousel Block that content editors can use on any page:

8. Next Steps & Best Practices

You've built a flexible block library and understand how to compose, validate, and render custom components. Here's what to tackle next:

Gataya Med

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

Comments (0)

Sarah Chen July 8, 2026

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

Reply

Leave a Comment