Implementing JSON-LD Structured Data in Django

Add JSON-LD structured data to a Django site using a flexible, view-based system. Improve SEO, enable rich results, and keep markup clean and maintainable.

Last Updated:

Tags: Django, SEO

Structured data is how Google and other search engines provide rich search results like ratings, cooking times, or article publish dates. Google supports three structured data formats: JSON-LD, Microdata, and RDFa, with Google recommending JSON-LD. This article demonstrates how to implement a flexible JSON-LD system in Django to improve site search visibility.

JSON-LD Implementation Difficulties

Adding structured data to a Django site often surfaces a subtle challenge: the need to inject JSON-LD in multiple places throughout the codebase. Ideally, structured data should be:

  • Defined site-wide (e.g. for WebSite or Organization schemas)
  • Customized per view or model (e.g. BlogPosting or Product)
  • Extended dynamically for specific pages (e.g. conditional FAQ data, event metadata, etc.)

Django doesn't offer a clean way to combine these layers into a single JSON-LD output. Instead, attempts often lead to:

  • JSON's strict syntax, where a trailing comma could break the script
  • Multiple <script> tags, cluttering the HTML head
  • Duplicated logic across templates and views
  • No clear separation between site-wide data and page-specific schemas

The Solution

This implementation solves the problem by combining three concepts:

  1. The @graph property in JSON-LD allows multiple schemas to be combined into a single JSON-LD object. This ensures all structured data can be output as a single <script> tag while preserving semantic relationships.
  2. A context processor defines global structured data (e.g. the WebSite schema) and initializes the structure needed for downstream use.
  3. Template tags and the view context allow each view to append page-specific data to an object named structured_data_list. These entries are merged automatically in the final rendered output.

Example output:

{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "WebSite", "name": "YourSite", ... },
    { "@type": "BlogPosting", "headline": "Your Post", ... },
    { "@type": "Person", "name": "Author Name", ... }
  ]
}

A single script tag keeps structured data manageable while enhancing semantic relationships for search engines.

Implementation

Context Processor

The context processor defines default structured data shared across all pages on the website (top-level objects like WebSite), and initializes an empty list for view-specific data to be appended later.

# core/context_processors.py
def jsonld_context(request):
    """Add site-wide JSON-LD data to context."""
    website_jsonld = {
        "@context": "https://schema.org",
        "@type": "WebSite",
        "name": "YourSite.com",
        "url": request.build_absolute_uri('/'),
    }

    return {
        'website_jsonld': website_jsonld,
        'structured_data_list': []
    }

Register the processor in TEMPLATES within settings.py:

TEMPLATES = [
    {
        'OPTIONS': {
            'context_processors': [
                # ... other processors
                'core.context_processors.jsonld_context',
            ],
        },
    },
]

Custom Template Tags

The template tag handles final rendering. It collects both site-wide and view-specific schema objects, combines them into an @graph, and outputs a single JSON-LD script tag. This ensures clean, valid markup without duplication.

# core/templatetags/jsonld_tags.py
import json
from django import template
from django.utils.safestring import mark_safe

register = template.Library()

@register.simple_tag(takes_context=True)
def render_all_jsonld(context):
    """Render all structured data items as a JSON-LD graph."""
    items = context.get('structured_data_list', []).copy()

    # Add website data if it exists
    if 'website_jsonld' in context:
        website_data = context['website_jsonld'].copy()
        if "@context" in website_data:
            del website_data["@context"]
        items.append(website_data)

    if not items:
        return ''

    # Create the graph
    graph = {
        "@context": "https://schema.org",
        "@graph": items
    }

    # Render as script tag
    json_str = json.dumps(graph, indent=2)
    return mark_safe(f'<script type="application/ld+json">{json_str}</script>')

Base Template Integration

Include the rendering tag in the base template’s <head> block. This ensures the combined JSON-LD output appears on every page that uses the layout.

{% load jsonld_tags %}
<!DOCTYPE html>
<html lang="en">
<head>
    <!-- Other head elements -->
    {% block json_ld %}
        {% render_all_jsonld %}
    {% endblock json_ld %}
</head>
<body>
    <!-- Page content -->
</body>
</html>

Implementing Schemas in Views

Individual views can contribute structured data to the page by adding items to structured_data_list. This allows each page to describe its specific content while still benefiting from the shared base setup.

Blog Post List View

This view defines two schema entries:

  1. A CollectionPage to describe the list view as a whole
  2. An ItemList to expose the individual blog posts with headlines and publish dates
def post_list(request):
    posts = Post.objects.filter(is_published=True).order_by('-created_at')

    # Define the collection page schema
    blog_list_data = {
        "@type": "CollectionPage",
        "name": "Blog Posts",
        "description": "Collection of blog posts"
    }

    # Add an ItemList for better search result display
    if posts.exists():
        articles_data = {
            "@type": "ItemList",
            "itemListElement": [
                {
                    "@type": "ListItem",
                    "position": i+1,
                    "item": {
                        "@type": "BlogPosting",
                        "headline": post.title,
                        "url": request.build_absolute_uri(post.get_absolute_url()),
                        "datePublished": post.created_at.isoformat()
                    }
                } for i, post in enumerate(posts[:10])  # Limit for performance
            ]
        }

        structured_data_list = [blog_list_data, articles_data]
    else:
        structured_data_list = [blog_list_data]

    return render(request, 'blog/post_list.html', {
        'posts': posts,
        'structured_data_list': structured_data_list
    })

Blog Post Detail View

This view describes a single blog post using the BlogPosting schema. If an image is available, it's included as well.

def post_detail(request, slug):
    post = get_object_or_404(Post, slug=slug)

    # Create detailed blog post schema
    blog_data = {
        "@type": "BlogPosting",
        "headline": post.title,
        "description": post.description,
        "author": {
            "@type": "Person",
            "name": "Your Name",
        },
        "datePublished": post.created_at.isoformat(),
        "dateModified": post.updated_at.isoformat(),
        "mainEntityOfPage": {
            "@type": "WebPage",
            "@id": request.build_absolute_uri(post.get_absolute_url())
        }
    }

    # Add image if available
    if post.featured_image:
        blog_data["image"] = request.build_absolute_uri(post.featured_image.url)

    return render(request, 'blog/post_detail.html', {
        'post': post,
        'structured_data_list': [blog_data]
    })

Extending the System

The structured data can be expanded further by adding to the structured_data_list.

This view demonstrates a Product schema with nested Offer data. The availability dynamically reflects inventory status.

def product_detail(request, slug):
    product = get_object_or_404(Product, slug=slug)
    
    product_data = {
        "@type": "Product",
        "name": product.name,
        "description": product.description,
        "offers": {
            "@type": "Offer",
            "price": str(product.price),
            "priceCurrency": "USD",
            "availability": "https://schema.org/InStock" if product.in_stock else "https://schema.org/OutOfStock"
        }
    }
    
    return render(request, 'shop/product_detail.html', {
        'product': product,
        'structured_data_list': [product_data]
    })

Takeaways

This approach to structured data in Django separates concerns while keeping output clean and valid. Key advantages include:

  1. Single script tag output: combines all structured data into one <script> using the @graph property, avoiding clutter and validation issues.
  2. Clear separation of concerns: site-wide schemas live in the context processor, while view-specific schemas are defined close to their data
  3. Composable architecture: any view can append data to structured_data_list, allowing incremental adoption and page-level customization.

About the Author
profile picture of Andy Carnevale

Data engineer and full stack developer from Cincinnati.