Two audiences read this article. Content managers — skim the first two sections and section 5. You'll come away knowing what to ask the engineering team for, and why. Junior developers — section 3 onward has the code. Everything works on current Wagtail (6.x) with use_json_field=True as the default.

011. The limit of rich text editors

Every CMS — WordPress, Drupal, Sitecore, Contentful — eventually offers the same default: one big rich-text field per page, optionally with some shortcodes or blocks layered on top. It's seductive. Editors get a Word-like experience; nobody has to think hard about content structure.

It works beautifully until it doesn't. Here's how it breaks, every single time, in every organisation we've worked with:

  • Layout drift. One editor's pull-quote uses bold italic, the next uses an inline image with a coloured background, the next pastes from Word and brings <span style="font-family:..."> into your CMS. Six months in, no two pages look quite alike.
  • Brand inconsistency. The design team published guidelines. The CMS can't enforce them. Reviews become a Slack channel.
  • Opaque to other systems. Wanting to syndicate to a native app, a partner site, or feed an AI search index? Your content is an HTML blob. Every consumer has to re-parse it.
  • Accessibility regressions. Editors skip heading levels, paste images without alt text, embed videos without captions. The CMS lets them.
  • SEO costs. Inconsistent structure means inconsistent rich-result eligibility. Pages that should qualify for Article schema, FAQ schema, or breadcrumbs don't.

The root cause is the same in every case: the CMS treats your content as freeform text when it's actually structured data. A campaign landing page isn't one paragraph; it's a hero + a value-prop row + three statistic blocks + a CTA. A blog post isn't one giant text field; it's an introduction + a sequence of headings + paragraphs + the occasional pull-quote + a closing call-to-action.

The reframing that matters

Your content isn't text. It's structured data that happens to contain text. A CMS that doesn't model that structure is fighting your content team every day. StreamFields are Wagtail's answer to this problem — and the reason serious Wagtail builds outperform serious WordPress builds on editor satisfaction, brand consistency, and downstream integrations.

022. What StreamFields actually are

A StreamField is a Wagtail page field that holds an ordered list of typed blocks. Each block has a known shape (a known set of fields, with known types, validated when the editor saves). The page rendering walks the list and renders each block according to its type.

The mental model in one sentence: "my page body is a list of LEGO pieces, where each piece type has a known shape, and editors compose the page by adding pieces in order".

The anatomy of a StreamField page

In Python, declaring a StreamField looks like this — the simplest possible example, deliberately under-engineered to make the structure clear:

blog/models.py · simplest StreamField python
from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail.admin.panels import FieldPanel
from wagtail import blocks


class ArticlePage(Page):
    body = StreamField([
        ('heading',   blocks.CharBlock(form_classname='title')),
        ('paragraph', blocks.RichTextBlock()),
        ('image',     ImageChooserBlock()),
        ('quote',     blocks.BlockQuoteBlock()),
    ], use_json_field=True)

    content_panels = Page.content_panels + [
        FieldPanel('body'),
    ]

What this gives the editor: when they edit an ArticlePage, they see a "Body" area with an Add a block button. Clicking it shows four options — Heading, Paragraph, Image, Quote. They pick one, fill it in, save. Then they add another. The order matters; they can drag blocks to reorder them.

What it gives the developer: in your template, the body is iterable. Each block knows its own type and how to render itself:

templates/blog/article_page.html · rendering html
<article>
  <h1>{{ page.title }}</h1>
  {% for block in page.body %}
    {% include_block block %}
  {% endfor %}
</article>

That's it. The {% include_block %} tag looks up the right template for the block type and renders it. No giant if/else in your view; no JavaScript hydration step; no second pass to parse HTML blobs. The structure is preserved end-to-end from editor click to rendered page.

033. The built-in block types

Wagtail ships with a generous set of block types out of the box. You can build most of a real site using nothing else. Memorise these:

  • CharBlock — single-line text. Use for headings, labels, short captions.
  • TextBlock — multi-line plain text (no formatting). Use for code snippets the editor types in, raw HTML, debug notes.
  • RichTextBlock — Draftail rich-text editor. The bread-and-butter prose block. Always pass features=[...] to restrict what editors can do — see below.
  • EmailBlock, URLBlock, IntegerBlock, FloatBlock, DecimalBlock — typed single-value blocks. Validation comes for free.
  • BooleanBlock, DateBlock, TimeBlock, DateTimeBlock — equally self-explanatory.
  • ChoiceBlock — dropdown of editor-selectable values. Use for "callout type: info / warning / success" etc.
  • RawHTMLBlock — escape hatch for editors to paste arbitrary HTML. Use sparingly; it's the single biggest source of XSS risk on Wagtail sites.
  • BlockQuoteBlock — pull-quote with attribution.
  • ImageChooserBlock — picks an image from the Wagtail image library, with focal-point cropping and renditions.
  • DocumentChooserBlock, SnippetChooserBlock, PageChooserBlock — pick from documents, reusable snippets, or other pages. The page chooser is what keeps internal links from breaking when content moves.
  • EmbedBlock — paste a YouTube / Vimeo / Twitter URL, get a proper embed. Powered by oEmbed.

Restrict RichTextBlock features

This is the single most important habit to develop. The default RichTextBlock() gives editors the entire toolbar — H1 through H6, blockquote, bullet/numbered lists, bold, italic, underline, links, embeds, images. That's almost always too much.

features parameter — restrict editor freedom python
blocks.RichTextBlock(
    features=['h2', 'h3', 'bold', 'italic', 'link', 'ol', 'ul']
)

This block now allows H2 + H3 headings, bold/italic/link inline formatting, and ordered/unordered lists. Editors can't insert H1 (you have one per page already), can't paste images inline (use the ImageChooserBlock instead), can't add raw HTML, can't underline (which Google ranks as a click signal). The restriction makes the editor's life simpler and protects your brand.

044. Creating your own blocks

Built-in blocks cover ~70% of real-world needs. The remaining 30% is where custom blocks earn their keep — and where StreamFields really start to shine over rich-text-only CMSes.

StructBlock — group fields into a reusable unit

StructBlock is the workhorse. Use it any time you have "a thing that has multiple fields" — a callout, a CTA, a statistic, a person card, a pricing tier.

core/blocks.py · CalloutBlock with variants python
from wagtail import blocks


class CalloutBlock(blocks.StructBlock):
    style = blocks.ChoiceBlock(
        choices=[
            ('info',    'Info (blue)'),
            ('warning', 'Warning (amber)'),
            ('success', 'Success (green)'),
        ],
        default='info',
    )
    title = blocks.CharBlock(required=False, max_length=80,
                              help_text='Optional. Shown bold above the body.')
    body  = blocks.RichTextBlock(features=['bold', 'italic', 'link'])

    class Meta:
        icon     = 'warning'             # icon shown in the Add-a-block menu
        label    = 'Callout'             # human-readable name
        template = 'blocks/callout.html'  # custom rendering
        group    = 'Highlights'          # groups blocks in the picker

The Meta nested class is where most editor-UX choices live. icon picks the icon Wagtail shows in the "Add a block" menu (there's a built-in icon set). label is what editors see. template tells Wagtail what to render. group lets you cluster related blocks together in the picker — when you have 30+ block types, this is the difference between "I'll find it" and "where's the callout block?"

The template uses the block's data via value:

templates/blocks/callout.html html
<aside class="callout callout--{{ value.style }}" role="note">
  {% if value.title %}<strong class="callout__title">{{ value.title }}</strong>{% endif %}
  <div class="callout__body">{{ value.body|richtext }}</div>
</aside>

Use it in your page model exactly like any built-in block:

blog/models.py · wiring CalloutBlock into ArticlePage python
from core.blocks import CalloutBlock


class ArticlePage(Page):
    body = StreamField([
        ('heading',   blocks.CharBlock()),
        ('paragraph', blocks.RichTextBlock(features=['bold', 'italic', 'link'])),
        ('callout',   CalloutBlock()),
        ('image',     ImageChooserBlock()),
    ], use_json_field=True)

StreamBlock and ListBlock — nesting

Real page schemas often need blocks that contain blocks. Two patterns cover most cases:

  • StreamBlock — a flexible inner list where the editor can choose from a set of allowed sub-block types. Use this for "an FAQ section that contains a mix of standard Q&A items and occasional pull-quote dividers."
  • ListBlock — a homogeneous inner list of one sub-block type. Use this for "a row of three statistic blocks" or "a list of team members."
core/blocks.py · StatRowBlock — three stats side by side python
class StatBlock(blocks.StructBlock):
    value = blocks.CharBlock(max_length=20, help_text='e.g. "99.98%" or "£3.2M"')
    label = blocks.CharBlock(max_length=60)


class StatRowBlock(blocks.StructBlock):
    heading = blocks.CharBlock(required=False)
    stats   = blocks.ListBlock(StatBlock(), min_num=2, max_num=4)

    class Meta:
        icon     = 'list-ul'
        label    = 'Stat row'
        template = 'blocks/stat_row.html'

The min_num / max_num arguments mean an editor can't publish a stat row with one stat or five — the form refuses to save. Constraints encoded in code; no Slack thread needed to enforce design rules.

Adding validation

For anything more complex than "min/max items," override clean() on your StructBlock. It runs server-side every time the editor saves, and any ValidationError you raise is shown next to the offending field in the admin.

cross-field validation python
from django.core.exceptions import ValidationError


class CtaBlock(blocks.StructBlock):
    label       = blocks.CharBlock()
    external_url = blocks.URLBlock(required=False)
    internal_page = blocks.PageChooserBlock(required=False)

    def clean(self, value):
        cleaned = super().clean(value)
        if cleaned.get('external_url') and cleaned.get('internal_page'):
            raise ValidationError('Pick an external URL OR an internal page, not both.')
        if not cleaned.get('external_url') and not cleaned.get('internal_page'):
            raise ValidationError('A CTA needs either an external URL or an internal page.')
        return cleaned

055. Improving editorial workflows

This is the section content managers should read most carefully. A StreamField that's technically correct but painful to use is a failure. The defaults Wagtail ships work, but a serious build invests in editor UX. Ten things we do on every project to make the admin feel hand-crafted:

  1. Set an icon on every block. Visual recognition is faster than reading. Wagtail's built-in icon set has 100+ icons; pick the one closest to the block's intent.
  2. Use group to cluster related blocks. When the picker has more than ~8 block types, group them into "Text", "Media", "Highlights", "Layout", "Embed" or similar. Editors find what they need in 1 click.
  3. Write help_text for every non-obvious field. The format hint for a date, the recommended length for a heading, the link to brand guidelines for an OG image. Editors don't open Confluence; they read help text.
  4. Provide block previews. Wagtail 5+ supports preview panels per block — show the editor exactly what the block will look like as they type. Custom preview_template on a block, and they see the rendered output live.
  5. Restrict heading levels. H1 is the page title; editors should pick from H2 and H3 only. A ChoiceBlock of allowed levels — never a free-text "heading style" — prevents accidental SEO regressions.
  6. Cap StreamField length on bounded templates. A landing page schema should probably enforce max_num on the StreamField itself, or use block_counts to say "max 1 hero, max 3 CTAs."
  7. Provide example content. Use Wagtail's preview_value (Wagtail 6+) to render a sensible default in the block preview. Editors instantly see what they're picking.
  8. Build an editor handbook in Wagtail itself. Use a Snippet model called "EditorialGuideline" with rich text. Link to it from every block's help text. Marketing owns it; engineering doesn't.
  9. Wire up the accessibility checker. Wagtail's built-in Axe-core integration runs on every preview. Configure it to block publication on critical violations. Editors fix issues before they reach production, not after a complaint.
  10. Run editor research sessions. Watch a real editor use the admin for 20 minutes. You'll spot ten things to fix that no developer would have noticed.
The editor-first principle

The single biggest predictor of whether a Wagtail build succeeds in production isn't the code quality — it's whether editors enjoy using the admin. Time spent making the editor experience genuinely delightful pays back many times over in editorial throughput, retention, and content quality. Treat the admin as a first-class product, not an afterthought.

066. When NOT to use StreamFields

StreamFields are powerful, but they're not the right answer for every field on every page. A few patterns where a plain Django field is better:

  • Single-value fields. A "publication date" is a DateField. A "subtitle" is a CharField. Don't wrap them in StreamFields just because you can — you lose validation, queryability, and admin clarity.
  • Fields you query against. StreamField content is stored as JSON. Filtering "all pages with a hero block where the headline contains X" is possible but slow and awkward. If you need to query it, model it as relational fields instead.
  • True freeform editorial. A short news article that's genuinely just headline + paragraphs + occasional image is fine as a single RichTextField. Don't impose structure where editors genuinely need narrative flow.
  • External-system mirrors. If a field is the canonical copy of data that lives in Salesforce / Stripe / Hubspot, model it as the data it actually is. A StreamField hiding "this row was synced from CRM" is a debugging nightmare.

077. Common questions

"What's the difference between StreamField, StructBlock, StreamBlock and ListBlock?"

Easy to confuse, easy to explain. StreamField is the page-level field — the outer container that holds the ordered list of blocks. StructBlock is a single block made up of multiple fields (a callout with style + title + body). StreamBlock is a nested list of mixed sub-block types inside a StructBlock. ListBlock is a nested list of one sub-block type (a row of stats, a team-member list). You'll use StreamField + StructBlock on almost every page; reach for StreamBlock and ListBlock when you need nesting.

"Can editors break the site with a bad StreamField change?"

Practically no, if you've set things up correctly. Block-level validation runs on every save. Required fields are enforced. The template rendering is type-aware — if a block doesn't exist, {% include_block %} simply skips it rather than throwing. The worst case is "the page looks slightly wrong" — not "the site is down." This is the major reason content managers prefer Wagtail to WordPress once they've experienced both.

"What happens when we add or rename a block type later?"

Renaming is handled by Wagtail's standard migration framework. You give old block content a migration path (Wagtail has tooling for this — see the docs on data migrations for StreamField). Adding new block types is zero-effort: editors see the new option in the picker the moment you deploy. Removing a block type that's already in use requires a one-time data migration to convert existing instances to something else.

"How does this work with a headless setup (Next.js, native mobile)?"

StreamField data is serialised cleanly via Wagtail's REST and GraphQL APIs as typed JSON. Each block exposes its type identifier and its data. The frontend renders each block type with a matching component. Schema-first content modelling pays back twice — once for the Django-rendered site, once again for any headless consumer. Our headless walkthrough shows the full pattern.

"I'm ready for the advanced patterns. Where next?"

Our companion article — Wagtail StreamField: advanced patterns we use on every project — picks up where this one leaves off. It covers nested StructBlock composition, custom chooser blocks (pulling from external data sources), block-level previews, migration strategy at scale, and integrating StreamField content with Wagtail's search engine. For the strategic SEO implications of structured content, the Wagtail SEO guide is the natural next step.

If you're scoping a Wagtail build and want help shaping the content model, the 2-week Discovery Sprint is exactly designed for that conversation — fixed price, written outputs you can hand to your team, and no obligation to engage us for the build.