Wagtail StreamField:
Advanced Patterns We Use
on Every Project

StructBlock nesting, custom choosers, block-level validation, and the rendering patterns that turn a working StreamField into one your editors and developers will actually enjoy.

StreamField is the feature that makes Wagtail worth learning. Once you've used it properly — defined your content as typed, composable blocks rather than a freeform rich text blob — it becomes difficult to imagine building a content-managed site any other way.

But StreamField has a learning curve. Most introductions show you how to add a RichTextBlock and call it done. Production Wagtail projects go considerably further. This post covers the patterns we apply consistently across projects — the ones that make StreamField maintainable at scale.

Nested StructBlocks: building reusable components

The most powerful feature of StreamField isn't any individual block type — it's composability. StructBlocks can be nested arbitrarily, and a well-designed block hierarchy maps directly onto your design system's component structure.

We define reusable blocks in a dedicated blocks.py module, imported wherever they're needed:

from wagtail.blocks import (
    StructBlock, CharBlock, RichTextBlock,
    ImageChooserBlock, URLBlock, ListBlock,
)


class LinkBlock(StructBlock):
    text = CharBlock(label="Link text")
    url = URLBlock(label="URL")
    open_in_new_tab = BooleanBlock(required=False, label="Open in new tab")

    class Meta:
        icon = "link"
        label = "Link"


class CallToActionBlock(StructBlock):
    heading = CharBlock()
    body = RichTextBlock(features=["bold", "italic", "ol", "ul"])
    primary_link = LinkBlock()
    secondary_link = LinkBlock(required=False)

    class Meta:
        icon = "pick"
        label = "Call to action"


class PersonBlock(StructBlock):
    name = CharBlock()
    role = CharBlock(required=False)
    photo = ImageChooserBlock(required=False)
    bio = RichTextBlock(features=["bold", "italic"])

    class Meta:
        icon = "user"


# Reuse across page types:
class EventPage(Page):
    body = StreamField([
        ("text", RichTextBlock()),
        ("cta", CallToActionBlock()),
        ("speakers", ListBlock(PersonBlock())),
    ])

LinkBlock and PersonBlock get used across a dozen page types. Define them once, test them once, update them in one place.

Note on ListBlock ListBlock(PersonBlock()) gives editors an ordered, repeatable list of person entries with add/remove/reorder controls. It's the right tool whenever you have a variable number of items with the same structure — speakers, testimonials, FAQ items, team members.

Custom chooser blocks

Wagtail ships with PageChooserBlock, ImageChooserBlock, DocumentChooserBlock, and SnippetChooserBlock. For anything else, you write a custom chooser.

The most common case is a snippet chooser — you have a Testimonial or CaseStudy snippet model, and you want to embed it inline within a StreamField. SnippetChooserBlock handles this directly:

from wagtail.snippets.blocks import SnippetChooserBlock

class TestimonialsBlock(StructBlock):
    heading = CharBlock(required=False)
    items = ListBlock(SnippetChooserBlock("testimonials.Testimonial"))

    class Meta:
        icon = "openquote"
        label = "Testimonials"

For page choosers that restrict to a specific page type, use PageChooserBlock with a target_model argument:

("featured_case_study", PageChooserBlock(target_model="work.CaseStudyPage"))

If you need a completely custom chooser widget — say, to select from a third-party API — you implement BaseChooserBlock. That's a longer topic, but the pattern is well-documented in the Wagtail source for ImageChooserBlock.

Block-level validation

Every block can implement a clean() method to run validation beyond what field-level required checks cover. This is one of the most underused features of StreamField.

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


class EventDateBlock(StructBlock):
    start = DateTimeBlock(label="Start date & time")
    end = DateTimeBlock(label="End date & time")
    location = CharBlock(required=False)

    def clean(self, value):
        result = super().clean(value)
        errors = {}
        if result["end"] <= result["start"]:
            errors["end"] = ValidationError(
                "End time must be after start time."
            )
        if errors:
            raise StructBlockValidationError(block_errors=errors)
        return result

Wagtail renders these errors inline, next to the specific field that failed — the editor sees exactly what went wrong without submitting the page and losing context.

You can also raise a non-field error by passing a non_block_errors argument to StructBlockValidationError. We use this for cross-field constraints, like requiring at least one of two optional fields to be filled in.

Template rendering patterns

Blocks render via templates. By default, Wagtail looks for blocks/<block_name>.html. For StructBlocks, define the template in the Meta class:

class CallToActionBlock(StructBlock):
    # ... fields ...

    class Meta:
        template = "blocks/cta.html"

Inside blocks/cta.html, the block's value is available as value:

<section class="cta-block">
  <h2>{{ value.heading }}</h2>
  <div>{{ value.body }}</div>
  <a href="{{ value.primary_link.url }}"
    {% if value.primary_link.open_in_new_tab %}target="_blank" rel="noopener noreferrer"{% endif %}>
    {{ value.primary_link.text }}
  </a>
</section>

One subtlety: RichTextBlock values must be passed through the richtext template filter to render HTML correctly. {{ value.body|richtext }} — not just {{ value.body }}. This catches people regularly.

get_context() for computed data If a block needs to pass additional context to its template — say, a RecentPostsBlock that queries the database — override get_context() rather than doing the query in the template tag. This keeps logic in Python and makes blocks independently testable.

Migration strategy for StreamField changes

StreamField changes require careful migration planning in production. Django generates a migration whenever you add, remove, or rename a block — but the migration only changes the field definition, not the stored JSON data.

This means:

  • Adding a new optional block type — safe, no data migration needed.
  • Renaming a block type — requires a data migration to update the stored type key in existing JSON, otherwise old content silently disappears from the stream.
  • Adding a required field to an existing StructBlock — existing content will fail validation when editors try to edit those pages. Either make the field non-required, or run a data migration to backfill values.
  • Removing a block type — existing instances of that block become unrendered silently. Safe to remove the block type from Python once no pages have instances of it (check with a management command first).

We write a helper management command for each project that reports block type usage across all StreamField pages. Running it before any destructive StreamField change is just good practice.

Wagtail's search indexer knows how to extract text from most block types automatically when you add the StreamField to search_fields:

search_fields = Page.search_fields + [
    index.SearchField("body"),
]

But it can miss content inside deeply nested custom blocks unless you implement get_searchable_content() on those blocks. For any block that contains text the user wrote — even indirectly — add:

class CallToActionBlock(StructBlock):
    # ...

    def get_searchable_content(self, value):
        content = []
        if value.get("heading"):
            content.append(value["heading"])
        if value.get("body"):
            content += super().get_searchable_content(value["body"])
        return content

The pattern we use on every project

Every Wagtail project we build has a BasePage model with a shared COMMON_BLOCKS list, and page-specific StreamFields extend it:

COMMON_BLOCKS = [
    ("rich_text", RichTextBlock(features=RICH_TEXT_FEATURES)),
    ("image", ImageBlock()),
    ("cta", CallToActionBlock()),
    ("html", RawHTMLBlock(label="Raw HTML (advanced)")),
]

class ArticlePage(Page):
    body = StreamField(
        COMMON_BLOCKS + [
            ("pullquote", PullquoteBlock()),
            ("code", CodeBlock()),
        ],
        use_json_field=True,
    )

The use_json_field=True argument is required from Wagtail 5.0 onwards and stores StreamField data as native JSON rather than a text blob. Always use it on new projects — it enables proper database querying of block content.

StreamField rewards the time you spend designing it upfront. The content model you define in Python is the editorial interface your team will work in for years. Getting the blocks right at the start — typed, composable, validated — pays dividends every time a new page type needs to be added.

Work with us

Building something with Wagtail?

We've built StreamField content models for publishers, charities, universities, and SaaS companies. Tell us what you're working on.