Guide: JSON-LD structured data

django-seo-suite ships a library of ready-made schema.org profiles. Enable them with a single class attribute, with no template editing required.


Enabling profiles on a model

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


class Article(SeoModelMixin, models.Model):
    headline = models.CharField(max_length=200)
    blurb = models.TextField(blank=True)
    cover = models.ImageField(upload_to="covers/", blank=True)
    published_at = models.DateTimeField()
    updated_at = models.DateTimeField(auto_now=True)
    slug = models.SlugField(unique=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()),
        ]

{% seo_head %} renders each profile as a separate <script type="application/ld+json"> block.


Free profile library

All profiles below are registered by default. Reference them by key in SEO_SCHEMA_PROFILES (on a model) or seo_schema_profiles (on a view).

WebPage

Key: "WebPage"

Reads title/name/headlinename, description/summary/excerptdescription, get_absolute_url()url. Always emitted (even with no matching fields).

WebSite

Key: "WebSite"

Reads SEO_SUITE["DEFAULTS"]["og"]["site_name"]name, request root → url. Typically added to a homepage view.

Organization

Key: "Organization"

Reads SEO_SUITE["DEFAULTS"]["og"]["site_name"]name, request root → url. Pair with WebSite on a homepage.

Person

Key: "Person"

Reads full_name/name/get_full_name()name, get_absolute_url()url. Use on author profile pages.

Key: "BreadcrumbList"

Reads get_breadcrumbs() (or a breadcrumbs attribute). The method must return a list of (name, url) tuples or a list of dicts with "name" and "url" keys:

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

    # Dict form (equivalent):
    return [
        {"name": "Home", "url": "/"},
        {"name": "Articles", "url": "/articles/"},
        {"name": self.headline, "url": self.get_absolute_url()},
    ]

Emitted only when at least one valid item is present.

Article

Key: "Article"

JSON-LD field Source attributes tried
headline headline, title, name
description meta_description, description, summary, excerpt
image og_image, image, cover_image, thumbnail
datePublished date_published, published_at, published, pub_date, created
dateModified date_modified, modified, updated_at, updated
author author_name, author (wrapped as {"@type":"Person","name":...})
mainEntityOfPage get_absolute_url()

FAQPage

Key: "FAQPage"

Reads get_faqs() (or a faqs attribute). The method must return a list of (question, answer) tuples or dicts with "question"/"answer" keys (also accepts "q"/"a" shorthand):

def get_faqs(self):
    return self.faq_items.values_list("question", "answer")
    # or:
    return [
        {"question": "What is …?", "answer": "It is …"},
        {"q": "How do I …?", "a": "You can …"},
    ]

Emitted only when at least one complete question/answer pair is present.

Product

Key: "Product"

JSON-LD field Source attributes tried
name name, title
description meta_description, description, summary
image og_image, image, cover_image
sku sku
brand brand (wrapped as {"@type":"Brand","name":...})
offers Built from price, currency/price_currency, availability

Overriding profile fields with get_schema_data

Any model can supply or override specific fields for a profile without subclassing it:

class Article(SeoModelMixin, models.Model):
    headline = models.CharField(max_length=200)
    SEO_SCHEMA_PROFILES = ["Article"]

    def get_schema_data(self, key, context=None):
        if key == "Article":
            return {
                "inLanguage": "en-US",
                "publisher": {
                    "@type": "Organization",
                    "name": "My Blog",
                    "logo": {"@type": "ImageObject", "url": "https://example.com/logo.png"},
                },
            }
        return {}

The dict returned by get_schema_data is merged on top of the profile's automatic output (your keys win over the profile's keys). Return {} or None for profiles you do not want to modify.


Rich images with get_schema_image

By default a profile's image field is a bare URL string (resolved from og_image, image, cover_image, thumbnail). When you also have the full-size original and its dimensions, you can emit a schema.org ImageObject instead — Google prefers images with explicit dimensions for rich results.

Implement the optional get_schema_image(key, context) hook. Whatever it returns is used verbatim as the profile's image value — a string, an ImageObject dict, or a list of either. django-seo-suite does not process your images; map your own thumbnail library (easy-thumbnails, sorl, imagekit, …) however you like:

class Article(SeoModelMixin, models.Model):
    cover = models.ImageField(upload_to="covers/")
    SEO_SCHEMA_PROFILES = ["Article", "BreadcrumbList"]

    def get_schema_image(self, key, context=None):
        # The `key` argument is a per-profile switch: return a value only
        # for the profiles you want, and None to fall back / skip.
        if key != "Article":
            return None
        thumb = get_thumbnailer(self.cover)["card"]  # your thumbnail backend
        return {
            "@type": "ImageObject",
            "contentUrl": self.cover.url,
            "thumbnailUrl": thumb.url,
            "width": self.cover.width,
            "height": self.cover.height,
        }

Resolution order for the image field: the convention bare-string is set first, then get_schema_image overrides it, then get_schema_data (above) wins last if it also returns an image key.

The hook is entirely optional — without it, behaviour is unchanged. The key argument lets one method serve every profile while you decide which ones carry an image (return None to skip).

Caching on high-traffic sites

get_schema_image runs at resolve time, so a heavy hook (one that hits a thumbnail backend or probes image dimensions) is called once per request only. Enable the object resolution cache (SEO_SUITE["CACHE_TTL"] > 0, which requires a configured Django cache backend) and the hook's output is stored as part of the cached metadata and reused across requests — invalidated automatically when the object is saved or deleted. With caching off (the default), the hook runs on every resolve, so keep it cheap or cache inside it.


Enabling profiles on a view

Use seo_schema_profiles (note: no SEO_ prefix, lowercase) on a view:

class HomepageView(SeoViewMixin, TemplateView):
    template_name = "home.html"
    seo_title = "My Site"
    seo_schema_profiles = ["WebSite", "Organization"]

Site-wide default profiles

To add a profile to every page on the site, use DEFAULT_SCHEMA_PROFILES:

SEO_SUITE = {
    "DEFAULT_SCHEMA_PROFILES": ["WebSite"],
}

Profiles from DEFAULT_SCHEMA_PROFILES are merged alongside per-model and per-view profiles; JSON-LD blocks from all sources accumulate.


How JSON-LD accumulates

Unlike scalar fields where higher priority wins, JSON-LD blocks accumulate across all layers. A model that declares ["Article"] and a view that declares ["WebPage"] will emit both blocks on the same page. This is intentional: multiple structured-data types on one page is valid and often desirable.


Custom serializer

By default the serializer escapes <, >, and & characters using Unicode escapes to prevent injection through JSON-LD. If you need a different serialisation strategy (pretty-printing for debugging, custom escaping), swap the serializer:

# myapp/seo_utils.py
import json

def my_serializer(data: dict) -> str:
    return json.dumps(data, indent=2, ensure_ascii=False)
# settings.py
SEO_SUITE = {
    "JSONLD_SERIALIZER": "myapp.seo_utils.my_serializer",
}