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.


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",
}