Guide: Listing pages

List views (article archives, category pages, search results) need pagination-aware canonicals and titles. SeoListViewMixin handles both.


Basic list view

# blog/views.py

from django.views.generic import ListView
from seo_suite.mixins import SeoListViewMixin

from .models import Article


class ArticleList(SeoListViewMixin, ListView):
    model = Article
    queryset = Article.objects.filter(published=True).order_by("-published_at")
    paginate_by = 20
    seo_title = "All Articles"
    seo_description = "Browse the full archive."

Pagination-aware title

When ?page=2 is in the request, the title automatically becomes "All Articles – Page 2". Page 1 renders as "All Articles".

This behaviour comes from SeoListViewMixin.get_seo_title() and requires no extra code.


Pagination-aware canonical

By default, SeoListViewMixin sets the canonical URL to:

  • /articles/ on page 1
  • /articles/?page=2 on page 2, /articles/?page=3 on page 3, and so on

This is the recommended behaviour: it tells search engines that each paginated page has its own canonical URL rather than all pointing to page 1.

To disable page numbers in canonicals (all pages point to /articles/):

# settings.py
SEO_SUITE = {
    "LIST_CANONICAL_INCLUDES_PAGE": False,
}

Category pages that are also listing pages

A Category model can be both the object providing SEO metadata and the page presenting a list of items. Use both mixins:

# blog/models.py

from django.db import models
from seo_suite.mixins import SeoModelMixin


class Category(SeoModelMixin, models.Model):
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    slug = models.SlugField(unique=True)

    def get_absolute_url(self):
        return f"/categories/{self.slug}/"
# blog/views.py

from django.views.generic import ListView
from seo_suite.mixins import SeoListViewMixin

from .models import Article, Category


class CategoryDetail(SeoListViewMixin, ListView):
    model = Article
    template_name = "blog/category_detail.html"
    paginate_by = 20

    def get_queryset(self):
        self.category = Category.objects.get(slug=self.kwargs["slug"])
        return Article.objects.filter(category=self.category, published=True)

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["category"] = self.category
        return ctx

    def get_seo_title(self, context=None):
        # Delegate to the category object for the base title; the parent
        # class appends " – Page N" automatically.
        if hasattr(self, "category"):
            return self.category.name
        return None

    def get_seo_canonical(self, context=None):
        # Let the parent build the paginated canonical from the request path.
        return super().get_seo_canonical(context)

Note

When the view provides a title via get_seo_title, it runs at priority 50 (view layer), which overrides the object layer (priority 40). The category's description and other fields are not automatically pulled in through the view. To include the category object's full metadata, pass it as obj to attach_seo or override get_context_data to call resolve_seo(request, obj=self.category). For most listing pages, setting seo_title and seo_description directly on the view is simplest.


Pure listing views (no model)

For pages like a homepage or a static archive page, set attributes directly:

class HomepageView(SeoListViewMixin, ListView):
    model = Article
    template_name = "home.html"
    queryset = Article.objects.filter(published=True)[:5]

    seo_title = "My Blog"
    seo_description = "Thoughts on Python, Django, and the web."

h1 independent from title

The <title> tag and the visible <h1> heading on the page are often different. Set seo_h1 to control the heading independently:

class ArticleList(SeoListViewMixin, ListView):
    model = Article
    seo_title = "Articles | My Blog"  # browser tab / search result
    seo_h1 = "All Articles"           # visible page heading

Access it in the template:

<h1>{{ seo.h1 }}</h1>