Custom Wagtail StreamField Blocks Development
StreamField is Wagtail's mechanism allowing editors to build pages from structured blocks in arbitrary order. Standard library covers basic cases: text, image, embed. Custom blocks are needed when editor should input structured data — product card, pricing table, icons with text block — without leaving CMS.
Block Anatomy
Each StreamField block is a Python class inheriting from one of the base types. Simplest custom block:
# blocks.py
from wagtail.blocks import StructBlock, CharBlock, RichTextBlock, URLBlock
from wagtail.images.blocks import ImageChooserBlock
class FeatureCardBlock(StructBlock):
icon = ImageChooserBlock(required=False)
heading = CharBlock(max_length=80)
body = RichTextBlock(features=['bold', 'italic', 'link'])
cta_text = CharBlock(max_length=40, required=False)
cta_url = URLBlock(required=False)
class Meta:
icon = 'pick'
label = 'Feature Card'
template = 'blocks/feature_card.html'
Meta.template points to HTML template for rendering on frontend. Template receives value variable with block data:
{# blocks/feature_card.html #}
<div class="feature-card">
{% if value.icon %}
{% image value.icon width-64 as card_icon %}
<img src="{{ card_icon.url }}" alt="" class="feature-card__icon">
{% endif %}
<h3 class="feature-card__heading">{{ value.heading }}</h3>
<div class="feature-card__body">{{ value.body }}</div>
{% if value.cta_text and value.cta_url %}
<a href="{{ value.cta_url }}" class="btn">{{ value.cta_text }}</a>
{% endif %}
</div>
StructBlock with Nested Blocks
Blocks can be nested. Typical task — section with heading and card list:
from wagtail.blocks import ListBlock
class FeatureSectionBlock(StructBlock):
section_title = CharBlock(max_length=120)
layout = ChoiceBlock(choices=[
('grid-2', '2 columns'),
('grid-3', '3 columns'),
('grid-4', '4 columns'),
], default='grid-3')
cards = ListBlock(FeatureCardBlock())
class Meta:
icon = 'table'
label = 'Feature Section'
template = 'blocks/feature_section.html'
ListBlock wraps any block in dynamic list — editor adds/deletes elements in UI without limits.
StreamField in Page Model
# models.py
from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail.admin.panels import FieldPanel
from .blocks import FeatureSectionBlock, HeroBlock, TestimonialBlock, VideoEmbedBlock
class ServicePage(Page):
body = StreamField([
('hero', HeroBlock()),
('features', FeatureSectionBlock()),
('testimonials', TestimonialBlock()),
('video', VideoEmbedBlock()),
], use_json_field=True, blank=True)
content_panels = Page.content_panels + [
FieldPanel('body'),
]
use_json_field=True is required parameter starting from Wagtail 3.0. Data stored in jsonb PostgreSQL column, allowing queries into structure via Django ORM.
Migration:
python manage.py makemigrations
python manage.py migrate
Custom StructBlock with Validation
When standard field validation is insufficient — override clean():
from django.core.exceptions import ValidationError
from wagtail.blocks import StreamBlockValidationError, StructBlockValidationError
class PricingBlock(StructBlock):
plan_name = CharBlock()
monthly_price = DecimalBlock(min_value=0)
annual_price = DecimalBlock(min_value=0)
features = ListBlock(CharBlock())
def clean(self, value):
cleaned = super().clean(value)
errors = {}
if cleaned['annual_price'] >= cleaned['monthly_price'] * 12:
errors['annual_price'] = ValidationError(
'Annual price should be less than 12 months'
)
if len(cleaned['features']) == 0:
errors['features'] = ValidationError(
'Specify at least one plan feature'
)
if errors:
raise StructBlockValidationError(block_errors=errors)
return cleaned
class Meta:
label = 'Pricing Plan'
template = 'blocks/pricing.html'
ChooserBlock for Related Objects
If block should reference another page or snippet:
from wagtail.snippets.blocks import SnippetChooserBlock
from wagtail.blocks import PageChooserBlock
class RelatedLinksBlock(StructBlock):
title = CharBlock(max_length=60)
# Link to any page
page = PageChooserBlock(required=False)
# Link to snippet (e.g., case study)
case_study = SnippetChooserBlock('portfolio.CaseStudy', required=False)
# External link
external_url = URLBlock(required=False)
def clean(self, value):
cleaned = super().clean(value)
links = [cleaned['page'], cleaned['case_study'], cleaned['external_url']]
if not any(links):
raise StructBlockValidationError(
block_errors={'page': ValidationError('Specify at least one link')}
)
return cleaned
Block with Custom JavaScript in Wagtail Admin
For complex blocks sometimes custom widget in admin panel is needed. Wagtail 5+ supports Stimulus controllers:
from wagtail.blocks import StructBlock
from django import forms
class ColorPickerWidget(forms.TextInput):
class Media:
js = ['admin/js/color-picker.js']
def __init__(self, *args, **kwargs):
kwargs.setdefault('attrs', {})
kwargs['attrs']['data-controller'] = 'color-picker'
super().__init__(*args, **kwargs)
class BrandColorBlock(StructBlock):
label = CharBlock()
color = CharBlock(
form_classname='full',
# custom widget via FieldBlock
)
For non-standard interfaces — StructBlock with overridden get_form_context and custom template for formset.
API and Headless Mode
When using Wagtail as headless CMS, blocks serialize via Wagtail API v2. By default StreamField returns as array of {type, value, id} objects. For custom blocks add api_representation:
class FeatureCardBlock(StructBlock):
# ...fields...
def get_api_representation(self, value, context=None):
representation = super().get_api_representation(value, context)
# add computed fields
if value.get('icon'):
img = value['icon']
representation['icon_url'] = img.file.url
representation['icon_srcset'] = img.get_rendition('width-128').url
return representation
Timeline
One custom block with template and validation — 2–4 hours. Set of 8–12 blocks for typical corporate website (hero, sections, cards, testimonials, form, video, gallery, pricing table) — 3–5 working days including template markup and editor testing.







