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
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'),
]
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'
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},
}
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
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'
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>
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'
$ 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>'})]})]}
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
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'),
]
5. Common Errors & Solutions
| Error | Cause | Fix |
|---|---|---|
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 serializable | Trying to dump StreamField data without use_json_field=True | Set use_json_field=True on all StreamField definitions (Wagtail 4+) |
| Block silently not rendering on the page | Missing template for a custom block, or the template path is wrong | Verify 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 shell | The clean() method modifies an immutable StructValue in-place instead of returning a new one | Always call super().clean(value) first and work with the result dict |
| ListBlock items cannot be reordered in admin | Missing Wagtail admin JavaScript or using a third-party package that doesn't include sortable widget assets | Ensure 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:
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment