DEV Community

Cover image for Wagtail Routable Pages and Layout Configuration
Aidas Bendoraitis
Aidas Bendoraitis

Posted on • Originally published at djangotricks.com

Wagtail Routable Pages and Layout Configuration

If you are familiar with Wagtail CMS for Django, you know that you can create Wagtail pages and control their content and layout with blocks inside of stream fields. But what if you have entries coming from normal Django models through a routable page? In this article, I will explore how you can control the dynamic layout of a detail view in a routable page.

Routable pages in Wagtail are dynamic pages of your CMS page tree that can have their own URL subpaths and views. You can use them for filtered list and detail views, multi-step forms, multiple formats for the same data, etc. Here I will show you a routable ArticleIndexPage with a list and detail views for Article instances rendering the detail views based on the block layout in a detail_layout stream field.

Routable Wagtail page configuration

1. Project Setup

Create a Wagtail project myproject and articles app:

pip install wagtail
wagtail start myproject
cd myproject
python manage.py startapp articles
Enter fullscreen mode Exit fullscreen mode

Add to INSTALLED_APPS in your Django project settings:

INSTALLED_APPS = [
    ...
    "wagtail.contrib.routable_page",  # required for RoutablePage
    "myproject.apps.articles",
]
Enter fullscreen mode Exit fullscreen mode

2. File Structure

The articles app:

myproject/apps/articles/
├── __init__.py
├── apps.py
├── models.py          # Article, Category, ArticleIndexPage
├── blocks.py          # All StreamField block definitions
└── admin.py           # Register Article and Category in Django admin
Enter fullscreen mode Exit fullscreen mode

The articles templates:

myproject/templates/articles/
├── article_list.html           # List view
├── article_detail.html         # Detail view
└── blocks/
    ├── cover_image_block.html
    ├── description_block.html
    └── related_articles_block.html
Enter fullscreen mode Exit fullscreen mode

3. Models

myproject/apps/articles/models.py

Create the Category and Article Django models, and the ArticleIndexPage routable Wagtail page with article list and detail views:

from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _

from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
from wagtail.fields import StreamField
from wagtail.models import Page

from .blocks import article_detail_layout_blocks


class Category(models.Model):
    name = models.CharField(max_length=100, verbose_name=_("name"))
    slug = models.SlugField(unique=True, verbose_name=_("slug"))

    class Meta:
        verbose_name = _("category")
        verbose_name_plural = _("categories")

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=255, verbose_name=_("title"))
    slug = models.SlugField(unique=True, verbose_name=_("slug"))
    category = models.ForeignKey(
        Category,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="articles",
        verbose_name=_("category"),
    )
    cover_image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
        verbose_name=_("cover image"),
    )
    description = models.TextField(blank=True, verbose_name=_("description"))
    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at"))

    class Meta:
        verbose_name = _("article")
        verbose_name_plural = _("articles")

    def __str__(self):
        return self.title


class ArticleIndexPage(RoutablePageMixin, Page):
    """
    A single Wagtail page that owns:
      - /articles/          → paginated list of all Articles
      - /articles/<slug>/   → detail view for one Article

    The StreamField is edited once in the Wagtail admin and
    defines the layout for every detail view.
    """

    articles_per_page = models.IntegerField(default=10, verbose_name=_("articles per page"))

    detail_layout = StreamField(
        article_detail_layout_blocks(),
        blank=True,
        use_json_field=True,
        verbose_name=_("detail layout"),
        help_text=_(
            "Configure the layout for all article detail pages. "
            "Add, remove, and reorder blocks to change what appears "
            "on every article detail view."
        ),
    )

    # TabbedInterface gives List View and Detail View their own tabs.
    # promote_panels and settings_panels must be added explicitly here
    # because edit_handler takes full ownership of the admin UI structure.
    edit_handler = TabbedInterface([
        ObjectList(Page.content_panels + [FieldPanel("articles_per_page")], heading=_("List View")),
        ObjectList([FieldPanel("detail_layout")], heading=_("Detail View")),
        ObjectList(Page.promote_panels, heading=_("SEO / Promote")),
        ObjectList(Page.settings_panels, heading=_("Settings")),
    ])

    class Meta:
        verbose_name = _("article index page")
        verbose_name_plural = _("article index pages")

    @path("")
    def article_list(self, request):
        all_articles = Article.objects.select_related("category", "cover_image").order_by("-created_at")
        paginator = Paginator(all_articles, self.articles_per_page)
        page_number = request.GET.get("page")

        try:
            articles = paginator.page(page_number)
        except PageNotAnInteger:
            articles = paginator.page(1)
        except EmptyPage:
            articles = paginator.page(paginator.num_pages)

        return self.render(
            request,
            context_overrides={"articles": articles, "paginator": paginator},
            template="articles/article_list.html",
        )

    @path("<slug:article_slug>/")
    def article_detail(self, request, article_slug):
        article = get_object_or_404(
            Article.objects.select_related("category", "cover_image"),
            slug=article_slug,
        )
        return self.render(
            request,
            context_overrides={"article": article},
            template="articles/article_detail.html",
        )
Enter fullscreen mode Exit fullscreen mode

4. StreamField Blocks

myproject/apps/articles/blocks.py

Create Wagtail stream-field blocks for the cover image, description, and the related articles of an actual article. Each block can have some settings on how to represent the content of the block.

from django.utils.translation import gettext_lazy as _

from wagtail import blocks


class CoverImageBlock(blocks.StructBlock):
    aspect_ratio = blocks.ChoiceBlock(
        choices=[
            ("16-9", _("16:9 Widescreen")),
            ("4-3", _("4:3 Standard")),
            ("1-1", _("1:1 Square")),
            ("3-1", _("3:1 Banner")),
        ],
        default="16-9",
        label=_("Aspect ratio"),
        help_text=_("Controls the cropping of the cover image."),
    )

    class Meta:
        template = "articles/blocks/cover_image_block.html"
        icon = "image"
        label = _("Cover Image")


class DescriptionBlock(blocks.StructBlock):
    max_lines = blocks.IntegerBlock(
        min_value=0,
        default=0,
        label=_("Maximum lines"),
        help_text=_("Clamp the description to this many lines. Set to 0 to show all."),
        required=False,
    )

    class Meta:
        template = "articles/blocks/description_block.html"
        icon = "pilcrow"
        label = _("Description")


class RelatedArticlesBlock(blocks.StructBlock):
    sort_order = blocks.ChoiceBlock(
        choices=[
            ("newest", _("Newest first")),
            ("oldest", _("Oldest first")),
            ("title_asc", _("Title A → Z")),
            ("title_desc", _("Title Z → A")),
        ],
        default="newest",
        label=_("Sort order"),
        help_text=_("Order in which related articles are listed."),
    )

    def get_context(self, value, parent_context=None):
        context = super().get_context(value, parent_context=parent_context)
        article = (parent_context or {}).get("article")
        if not article or not article.category_id:
            context["related_articles"] = []
            return context

        from .models import Article

        sort_map = {
            "newest": "-created_at",
            "oldest": "created_at",
            "title_asc": "title",
            "title_desc": "-title",
        }

        context["related_articles"] = (
            Article.objects.select_related("category", "cover_image")
            .filter(category=article.category)
            .exclude(pk=article.pk)
            .order_by(sort_map.get(value["sort_order"], "-created_at"))[:3]
        )
        return context

    class Meta:
        template = "articles/blocks/related_articles_block.html"
        icon = "list-ul"
        label = _("Related Articles")


def article_detail_layout_blocks():
    """
    Returns the list of (name, block) tuples used in ArticleIndexPage.detail_layout.
    Defined as a function so models.py can import it without circular issues.
    """
    return [
        ("cover_image", CoverImageBlock()),
        ("description", DescriptionBlock()),
        ("related_articles", RelatedArticlesBlock()),
    ]
Enter fullscreen mode Exit fullscreen mode

The RelatedArticlesBlock here also has a customized context where we pass related_articles variable with 3 other articles of the same category sorted by the sorting order defined in the block.

5. Templates

articles/article_list.html

This will be the template for the paginated article list. Later you could augment it with a search form and filters.

{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags i18n wagtailroutablepage_tags %}

{% block content %}
<main class="article-index">
  <h1>{{ page.title }}</h1>
  <ul class="article-list">
    {% for article in articles %}
      <li class="article-card">
        {% if article.cover_image %}{% image article.cover_image width-400 as img %}
          <img src="{{ img.url }}" alt="{{ article.title }}">
        {% endif %}
        <h2>
          <a href="{% routablepageurl page "article_detail" article.slug %}">{{ article.title }}</a>
        </h2>
        {% if article.category %}<span class="badge">{{ article.category.name }}</span>{% endif %}
        <p>{{ article.description|truncatewords:30 }}</p>
      </li>
    {% empty %}
      <li>{% trans "No articles yet." %}</li>
    {% endfor %}
  </ul>
  {% if articles.has_other_pages %}
    <nav class="pagination" aria-label="{% trans 'Article pagination' %}">
      {% if articles.has_previous %}
        <a href="?page={{ articles.previous_page_number }}">{% trans "← Previous" %}</a>
      {% endif %}
      <span>{% blocktrans with num=articles.number total=articles.paginator.num_pages %}Page {{ num }} of {{ total }}{% endblocktrans %}</span>
      {% if articles.has_next %}
        <a href="?page={{ articles.next_page_number }}">{% trans "Next →" %}</a>
      {% endif %}
    </nav>
  {% endif %}
</main>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

articles/article_detail.html

The detail page would use the {% include_block page.detail_layout with article=article page=page %} to pass the article to the context of each block:

{% extends "base.html" %}
{% load i18n wagtailcore_tags wagtailroutablepage_tags %}

{% block content %}
<article class="article-detail">
  <header>
    <h1>{{ article.title }}</h1>
    {% if article.category %}<span class="badge">{{ article.category.name }}</span>{% endif %}
  </header>
  {% include_block page.detail_layout with article=article page=page %}
  <p>
    <a href="{% routablepageurl page "article_list" %}">{% trans "← Back to all articles" %}</a>
  </p>
</article>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

articles/blocks/cover_image_block.html

Cover image block would show the article cover image with the aspect ratio set in the block:

{% load wagtailimages_tags %}

{% if article.cover_image %}
  <div class="cover-image cover-image--{{ value.aspect_ratio }}">
    {% image article.cover_image width-1200 as img %}
    <img src="{{ img.url }}" alt="{{ article.title }}">
  </div>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

articles/blocks/description_block.html

Description block would hide the article description text overflow based on the max lines set in the block:

<section class="article-description">
  <p{% if value.max_lines > 0 %} class="line-clamp" style="-webkit-line-clamp: {{ value.max_lines }};"{% endif %}>
    {{ article.description }}
  </p>
</section>
Enter fullscreen mode Exit fullscreen mode

articles/blocks/related_articles_block.html

The related articles block would list the related articles as defined in the extra context of the block:

{% load i18n wagtailimages_tags wagtailroutablepage_tags %}

{% if related_articles %}
  <section class="related-articles">
    <h2>{% trans "Related Articles" %}</h2>
    <ul class="related-articles__list">
      {% for rel in related_articles %}
        <li class="related-card">
          {% if rel.cover_image %}{% image rel.cover_image width-400 as img %}
            <img src="{{ img.url }}" alt="{{ rel.title }}">
          {% endif %}
          <div class="related-card__body">
            {% if rel.category %}<span class="badge">{{ rel.category.name }}</span>{% endif %}
            <h3>
              <a href="{% routablepageurl page "article_detail" rel.slug %}">{{ rel.title }}</a>
            </h3>
            <p>{{ rel.description|truncatewords:20 }}</p>
          </div>
        </li>
      {% endfor %}
    </ul>
  </section>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

6. Django Admin Registration

articles/admin.py

Let's not forget to register admin views for the categories and articles so that we can add some data there:

from django.contrib import admin
from .models import Article, Category


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("name", "slug")
    prepopulated_fields = {"slug": ("name",)}


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ("title", "category", "created_at")
    list_filter = ("category",)
    search_fields = ("title", "description")
    prepopulated_fields = {"slug": ("title",)}
Enter fullscreen mode Exit fullscreen mode

7. Migrations and Initial Data

python manage.py makemigrations articles
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

8. Wagtail Admin Setup

  1. Open http://localhost:8000/cms/ and log in.
  2. In the Pages explorer, create an Article Index Page as a child of the root page.
    • Set the Slug to articles.
  3. On the List View tab, set Articles per page (e.g. 24).
  4. On the Detail View tab, open the Detail Layout StreamField and add blocks in your preferred order:
    • Cover Image — choose an aspect ratio.
    • Description — optionally set a maximum line count to clamp long descriptions.
    • Related Articles — choose the sort order for the three related articles shown.
  5. Publish the page.
  6. In the Django admin (/django-admin/), create some Categories and Articles with cover images and descriptions.
  7. Visit http://localhost:8000/articles/ for the paginated list.
  8. Click any article to see the detail view rendered using the StreamField layout you configured in step 4.

Final words

Using stream fields we can render not only editorial content, for example, images or rich-text descriptions, but also dynamic content based on values from other models and/or the context of the given template.

The approach illustrated in this article allows us to create Wagtail pages where content editors have freedom to adjust the layouts of the pages or insert blocks, such as ads or info texts, into specific places based on real-time events.

Top comments (0)