Wagtail snippets are the secret weapon for building DRY, maintainable content sites. Instead of duplicating team profiles, testimonials, or CTA banners across every page, you define a single model, register it as a snippet, and embed it anywhere with SnippetChooserBlock. This lesson walks you through building real-world reusable components step by step.

1. Learning Objectives

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

  • Understand what Wagtail snippets are and how they differ from Pages in the page tree
  • Create reusable Django models and register them as Wagtail snippets using @register_snippet
  • Build custom index views, list displays, and search configurations for snippet management
  • Integrate snippets into StreamField pages via SnippetChooserBlock and ModelChooserBlock
  • Build dynamic site components — testimonials, team members, CTAs — from reusable snippet data
  • Implement inline panels for nested models within snippets

2. Why This Matters (Real-World Scenario)

Imagine you are building a marketing website for a SaaS company. Every page needs a consistent footer with client testimonials, a "Meet the Team" section with employee bios, and promotional call-to-action banners that appear across multiple landing pages. Without a reusable component system, you would need to duplicate this content into every page's StreamField, creating a maintenance nightmare — updating a single testimonial or swapping out a team photo would require editing every page that uses it.

Wagtail snippets solve this problem elegantly. You define a Testimonial model once, register it as a snippet, and embed it into any StreamField via a SnippetChooserBlock. When you update the snippet, every page that references it automatically reflects the change. This is the Wagtail equivalent of a "component" system — clean, DRY, and maintainable at scale.

3. Core Concepts

3.1 What Are Snippets?

Snippets are Wagtail's mechanism for declaring Django models as reusable content fragments. Unlike Pages, snippets:

  • Have no URL, no menu entry, and no place in the page tree
  • Do not have version history by default (though you can add it with RevisionMixin)
  • Are managed through a dedicated Snippets section in the Wagtail admin
  • Can be embedded inside Pages via chooser blocks in StreamField
  • Provide a single source of truth — update one snippet, update everywhere

3.2 The @register_snippet Decorator

Any Django model can become a Wagtail snippet by applying the @register_snippet decorator. This makes the model appear in the Wagtail admin under the Snippets menu with automatic create/edit/delete views. You can customize these views with standard Wagtail panel definitions, list display settings, and search fields.

3.3 SnippetChooserBlock vs ModelChooserBlock

Wagtail provides two ways to reference snippets from StreamField:

  • SnippetChooserBlock — A StreamField block that opens a Wagtail-style chooser modal for a specific snippet model. This provides an admin-friendly selection experience with search and list display.
  • ModelChooserBlock — A more generic block that works with any Django model. It provides a simpler autocomplete field but lacks the full chooser modal experience.

For Wagtail snippets, always prefer SnippetChooserBlock — it provides the richest admin experience.

3.4 Custom Admin Panels and Configuration

Wagtail snippets support the same panel system as Pages. You can define panels, edit_handler, list_display, search_fields, and list_filter on your snippet model to control how it appears and behaves in the admin. This includes inline panels for related models (for example, FAQ items with question/answer pairs nested inside a snippet).

4. Hands-On Practice: Building a Team Member Snippet System

We will build a complete, reusable Team Member snippet system with StreamField integration. This covers the full lifecycle: defining the model, registering it, customizing the admin, and embedding it in a page.

4.1 Creating the Snippet Model

Create a new Django app or add to your existing Wagtail project:

python manage.py startapp snippets
Create the snippets app

Add the app to INSTALLED_APPS in your site's settings/base.py:

# wagtail_project/settings/base.py
INSTALLED_APPS = [
    ...
    'snippets',
]

Now define the TeamMember model in snippets/models.py. We use @register_snippet and standard Wagtail panels:

from django.db import models
from wagtail.admin.panels import FieldPanel, MultiFieldPanel, InlinePanel
from wagtail.snippets.models import register_snippet
from wagtail.models import Orderable
from modelcluster.fields import ParentalKey


@register_snippet
class TeamMember(models.Model):
    name = models.CharField(max_length=200)
    role = models.CharField(max_length=200)
    bio = models.TextField(blank=True)
    photo = models.ForeignKey(
        'wagtailimages.Image',
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )
    email = models.EmailField(blank=True)
    order = models.IntegerField(default=0)

    panels = [
        MultiFieldPanel([
            FieldPanel('name'),
            FieldPanel('role'),
            FieldPanel('email'),
        ], heading='Personal Info'),
        FieldPanel('photo'),
        FieldPanel('bio'),
        FieldPanel('order'),
    ]

    class Meta:
        ordering = ['order']

    def __str__(self):
        return self.name
Complete TeamMember snippet model

4.2 Running Migrations

Create and apply the database migration for your new model:

python manage.py makemigrations snippets
python manage.py migrate snippets
Run migrations for the snippets app

4.3 Customizing the Admin List View

Wagtail auto-generates a list view for registered snippets. Customize it with list_display, search_fields, and list_filter directly on the model class:

@register_snippet
class TeamMember(models.Model):
    name = models.CharField(max_length=200)
    role = models.CharField(max_length=200)
    bio = models.TextField(blank=True)
    # ... other fields ...
    order = models.IntegerField(default=0)

    panels = [ ... ]

    # Admin list configuration
    list_display = ['name', 'role', 'order']
    search_fields = ['name', 'role', 'bio']
    list_filter = ['role']

    class Meta:
        ordering = ['order']
Adding list/search/filter config to snippets

4.4 Creating a SnippetChooserBlock in StreamField

Now embed the TeamMember snippets inside any Page's StreamField. First, add SnippetChooserBlock to your page model:

from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail.admin.panels import FieldPanel
from wagtail.search import index

from wagtail.snippets.blocks import SnippetChooserBlock


class TeamPage(Page):
    template = 'team_page.html'

    body = StreamField([
        ('heading', blocks.CharBlock(form_classname='title')),
        ('paragraph', blocks.RichTextBlock()),
        ('team_member', SnippetChooserBlock('snippets.TeamMember')),
    ], use_json_field=True)

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

The SnippetChooserBlock('snippets.TeamMember') string is the app_label.ModelName dot-path. When the content editor opens the page, they can click the chooser button in StreamField to select a team member from a searchable modal.

4.5 Rendering the Snippet in Templates

In your page template, access the snippet instance through the block's value:

{% load wagtailimages_tags %}

{% for block in page.body %}
    {% if block.block_type == 'heading' %}
        <h2>{{ block.value }}</h2>
    {% elif block.block_type == 'paragraph' %}
        {{ block.value }}
    {% elif block.block_type == 'team_member' %}
        <div class="team-card">
            {% image block.value.photo fill-200x200 class="team-photo" %}
            <h3>{{ block.value.name }}</h3>
            <p class="role">{{ block.value.role }}</p>
            <p>{{ block.value.bio }}</p>
            <a href="mailto:{{ block.value.email }}">{{ block.value.email }}</a>
        </div>
    {% endif %}
{% endfor %}
Template rendering with block.value accessing snippet fields

4.6 Inline Panels — Nested Models Inside Snippets

Often you need related items inside a snippet — for example, FAQ items with question/answer pairs, or social media links for a team member. Use Wagtail's inline panel system with Orderable and ParentalKey from django-modelcluster:

from modelcluster.models import ClusterableModel
from modelcluster.fields import ParentalKey
from wagtail.models import Orderable


class SocialLink(Orderable):
    team_member = ParentalKey(
        'snippets.TeamMember',
        on_delete=models.CASCADE,
        related_name='social_links'
    )
    platform = models.CharField(max_length=50)
    url = models.URLField()

    panels = [
        FieldPanel('platform'),
        FieldPanel('url'),
    ]


@register_snippet
class TeamMember(ClusterableModel):
    # ... fields from before ...

    panels = [
        MultiFieldPanel([
            FieldPanel('name'),
            FieldPanel('role'),
            FieldPanel('email'),
        ], heading='Personal Info'),
        FieldPanel('photo'),
        FieldPanel('bio'),
        FieldPanel('order'),
        InlinePanel('social_links', label='Social Links'),
    ]
Inline panels for nested snippet data

Key changes: TeamMember inherits from ClusterableModel instead of models.Model (this enables cluster-based relation management), and SocialLink uses ParentalKey + Orderable for ordered inline relations. The InlinePanel in the panels list renders the social links formset directly inside the snippet editor.

4.7 Building a Testimonials Snippet with StreamField Integration

Another common use-case — client testimonials that appear across landing pages:

@register_snippet
class Testimonial(models.Model):
    client_name = models.CharField(max_length=200)
    company = models.CharField(max_length=200, blank=True)
    quote = models.TextField()
    rating = models.IntegerField(
        default=5,
        choices=[(i, str(i)) for i in range(1, 6)]
    )
    photo = models.ForeignKey(
        'wagtailimages.Image',
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )

    panels = [
        MultiFieldPanel([
            FieldPanel('client_name'),
            FieldPanel('company'),
        ], heading='Client'),
        FieldPanel('quote'),
        FieldPanel('rating'),
        FieldPanel('photo'),
    ]

    search_fields = ['client_name', 'company', 'quote']

    def __str__(self):
        return f'{self.client_name} — {self.company}'

Use it in any page's StreamField:

body = StreamField([
    ('heading', blocks.CharBlock()),
    ('paragraph', blocks.RichTextBlock()),
    ('testimonial', SnippetChooserBlock('snippets.Testimonial')),
], use_json_field=True)

4.8 Rendering the Testimonial in Templates

{% load wagtailimages_tags %}

{% for block in page.body %}
    {% if block.block_type == 'testimonial' %}
        <blockquote class="testimonial">
            <p>"{{ block.value.quote }}"</p>
            <footer>
                {% if block.value.photo %}
                    {% image block.value.photo fill-60x60 class="testimonial-photo" %}
                {% endif %}
                <cite>{{ block.value.client_name }}</cite>
                {% if block.value.company %}
                    <span>{{ block.value.company }}</span>
                {% endif %}
                <div class="stars">
                    {% for i in "12345"|make_list %}
                        {% if forloop.counter <= block.value.rating %}&#9733;{% else %}&#9734;{% endif %}
                    {% endfor %}
                </div>
            </footer>
        </blockquote>
    {% endif %}
{% endfor %}

5. Common Errors & Solutions

5.1 SnippetChooserBlock ImportError

Error: ImportError: cannot import name 'SnippetChooserBlock' from 'wagtail.snippets.blocks'

Solution: SnippetChooserBlock was added in Wagtail 4.0. If you're on an older version, use SnippetChooserBlock from wagtail.snippets.widgets instead, or upgrade your Wagtail version to 4.0+.

5.2 SnippetChooserBlock Registration Order

Error: LookupError: Model 'snippets.TeamMember' not found

Solution: The string argument to SnippetChooserBlock uses app_label.ModelName format. Make sure the model is properly registered with @register_snippet and the app is in INSTALLED_APPS. The model must be registered before Wagtail tries to resolve it during migration loading.

5.3 Missing ClusterableModel for Inline Panels

Error: AttributeError: 'TeamMember' object has no attribute 'social_links' or related items not saving

Solution: When using InlinePanel, the parent model must inherit from ClusterableModel (from modelcluster.models), not plain models.Model. This enables the cluster framework for relation management and automatic saving of child relations.

5.4 Snippet Not Appearing in Admin Menu

Issue: After adding @register_snippet, the snippet model doesn't appear in the admin's Snippets menu.

Solution: Make sure the models module is imported during Django's startup. Add an AppConfig.ready() import in apps.py or ensure the model is used somewhere in your project's startup path. The most reliable fix is to add from . import models in your app's __init__.py or use a default_app_config setting.

5.5 Template Rendering — Block Value Is an Object, Not a Dict

Error: Trying to access block.value.name but expecting a dict, or getting AttributeError

Solution: SnippetChooserBlock returns the actual model instance (TeamMember object), not a dictionary. Access fields directly as block.value.field_name (e.g., block.value.name, block.value.role). To check if a snippet was selected, test block.value — it will be None if no snippet was chosen.

6. Summary Checklist

  • Created a Django model and registered it with @register_snippet
  • Added the model's app to INSTALLED_APPS and run migrations
  • Customized the admin list view with list_display, search_fields, and list_filter
  • Used SnippetChooserBlock in a Page's StreamField to embed snippets
  • Rendered the snippet instance correctly in templates as a model object
  • Implemented inline panels with ClusterableModel and ParentalKey for nested data
  • Built at least one real-world snippet (TeamMember, Testimonial, or similar)

7. Practice Exercise

Build a Call-to-Action (CTA) Banner snippet system for a marketing website:

  1. Create a CTABanner snippet model with fields: title, subtitle, button_text, button_url, background_color (CharField with hex color choices), and an optional background image
  2. Register it with @register_snippet and add search_fields and list_display
  3. Create a SnippetChooserBlock('snippets.CTABanner') in your page's StreamField
  4. Render the CTA banner in your template with proper styling — a full-width section with the background color, heading overlay, and a styled call-to-action button
  5. Add an InlinePanel for CTA button links (supporting multiple buttons per banner), using an Orderable child model

Test that updating a CTA banner's text or color in the admin automatically updates every page that embeds it.

8. Next Steps

Congratulations! You now understand how to build reusable content components with Wagtail snippets and custom models. Your site can manage team members, testimonials, CTAs, and any other structured data from a single source of truth, embedded anywhere in your StreamField pages.

Coming up next: The Wagtail CMS series continues with Wagtail Search & Site Navigation: Search Backends, Indexing, and Navigation Menus, where you'll learn how to configure Wagtail's search backends (Elasticsearch, PostgreSQL, or database search), customize search indexing for pages and snippets, build custom search views with faceted filtering, and create dynamic navigation menus using Wagtail's menu system.

Further resources:

  • Wagtail Snippets Documentation — official reference for snippet registration and configuration
  • Wagtail Chooser Blocks — StreamField block types for referencing snippets and models
  • django-modelcluster Documentation — cluster framework for inline relations
  • Wagtail Panels Reference — all panel types available for customizing snippet admin forms

Common pitfalls to avoid:

  • Don't forget ClusterableModel when using InlinePanel — plain models.Model won't save child relations
  • Avoid over-fetching in templates — if you render many snippets on one page, use select_related or prefetch_related in a custom view
  • Remember that snippets have no version history by default — consider adding RevisionMixin for important models
  • Don't reference snippet models in StreamField definitions before the app is in INSTALLED_APPS

Gataya Med

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

Comments (0)

Sarah Chen July 13, 2026

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

Reply

Leave a Comment