Guide: Blog posts and articles

This guide covers the options for attaching SEO metadata to a model you own, from zero-configuration conventions through to per-row, admin-editable fields.


Level 1 — conventional field names (no configuration)

If your model already has fields named title, description (or summary or excerpt), and image (or cover_image, photo, thumbnail), and a get_absolute_url() method, SeoModelMixin picks them up with no further configuration:

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


class Article(SeoModelMixin, models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    image = models.ImageField(upload_to="articles/", blank=True)
    slug = models.SlugField(unique=True)

    def get_absolute_url(self):
        return f"/articles/{self.slug}/"

The full convention lookup order:

SEO field Tries, in order
title title, name, headline
meta_description meta_description, description, summary, excerpt
meta_keywords meta_keywords, keywords
og_image og_image, image, cover_image, photo, thumbnail
canonical_url return value of get_absolute_url()

For ImageField and FileField values, the mixin automatically calls .url when a file is uploaded; if the field is blank it contributes nothing.

A blank text field ("") also contributes nothing. It is treated the same as a missing field, so a lower-priority default can still fill the gap.


Level 2 — custom field names with SEO_FIELD_MAP

When your model's field names do not match the conventions, declare SEO_FIELD_MAP as a class attribute:

class Article(SeoModelMixin, models.Model):
    headline = models.CharField(max_length=200)
    blurb = models.TextField(blank=True)
    cover = models.ImageField(upload_to="covers/", blank=True)
    slug = models.SlugField(unique=True)

    SEO_FIELD_MAP = {
        "title": "headline",
        "meta_description": "blurb",
        "og_image": "cover",
    }

    def get_absolute_url(self):
        return f"/articles/{self.slug}/"

SEO_FIELD_MAP values are attribute names on the model. The mixin also accepts method names (it calls zero-argument callables for you).

You can map any SeoMetadata field: title, h1, meta_description, meta_keywords, og_image, robots, canonical_url.


Level 3 — per-row editor-editable SEO columns

When your editorial team needs to customise SEO for individual articles without touching code, use SeoModelFieldsMixin. It is an abstract model that adds seo_* columns directly to your table:

from seo_suite.mixins import SeoModelFieldsMixin


class Article(SeoModelFieldsMixin, models.Model):
    headline = models.CharField(max_length=200)
    blurb = models.TextField(blank=True)
    cover = models.ImageField(upload_to="covers/", blank=True)
    slug = models.SlugField(unique=True)

    SEO_FIELD_MAP = {
        "title": "headline",
        "meta_description": "blurb",
        "og_image": "cover",
    }

    def get_absolute_url(self):
        return f"/articles/{self.slug}/"

SeoModelFieldsMixin inherits from SeoModelMixin, so all conventions and SEO_FIELD_MAP still work. It then merges a higher-priority layer from its own columns:

Column Overrides
seo_title title
seo_description meta_description
seo_keywords meta_keywords
seo_robots robots
seo_canonical canonical_url
seo_og_image og_image

An empty seo_title column means "no opinion" — the convention fallback (headline via SEO_FIELD_MAP) is used instead. This means editors only need to fill in columns when they want to deviate from the automatic value.

Because SeoModelFieldsMixin is abstract, you create the migration:

python manage.py makemigrations blog
python manage.py migrate

Registering the fields in Django Admin

# blog/admin.py

from django.contrib import admin
from .models import Article


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    fieldsets = (
        (None, {
            "fields": ("headline", "blurb", "cover", "slug"),
        }),
        ("SEO", {
            "fields": (
                "seo_title",
                "seo_description",
                "seo_keywords",
                "seo_robots",
                "seo_canonical",
                "seo_og_image",
            ),
            "classes": ("collapse",),
            "description": (
                "Leave blank to use the article's headline and blurb automatically."
            ),
        }),
    )

Wiring to a detail view

# blog/views.py

from django.views.generic import DetailView
from seo_suite.mixins import SeoViewMixin

from .models import Article


class ArticleDetail(SeoViewMixin, DetailView):
    model = Article
    slug_field = "slug"
    slug_url_kwarg = "slug"

The view passes self.object to the resolver automatically. You do not call get_seo_metadata() yourself.


Adding JSON-LD Article schema

Add SEO_SCHEMA_PROFILES to emit an Article structured-data block alongside the meta tags:

class Article(SeoModelMixin, models.Model):
    headline = models.CharField(max_length=200)
    blurb = models.TextField(blank=True)
    cover = models.ImageField(upload_to="covers/", blank=True)
    slug = models.SlugField(unique=True)
    published_at = models.DateTimeField()
    updated_at = models.DateTimeField(auto_now=True)
    author = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True)

    SEO_FIELD_MAP = {
        "title": "headline",
        "meta_description": "blurb",
        "og_image": "cover",
    }
    SEO_SCHEMA_PROFILES = ["Article", "BreadcrumbList"]

    def get_absolute_url(self):
        return f"/articles/{self.slug}/"

    def get_breadcrumbs(self):
        return [
            ("Home", "/"),
            ("Articles", "/articles/"),
            (self.headline, self.get_absolute_url()),
        ]

The Article profile reads headline, blurb, cover.url, published_at, updated_at, and author automatically, matching the field names above. See JSON-LD structured data for a full profile reference.


Overriding specific fields at the view level

Use view-level attributes when you want to force a particular SEO value regardless of the object — for example, a fixed robots directive for draft content:

class DraftArticleDetail(SeoViewMixin, DetailView):
    model = Article
    seo_robots = "noindex,nofollow"

The view layer (priority 50) always wins over the object layer (priority 40). See Detail views for more patterns.