How resolution works

Every page request runs the same pipeline: collect metadata from several providers, merge the layers in precedence order, and finalize the result into a renderable object. Understanding this model makes every other feature easier to reason about.


The precedence ladder

Providers are merged from lowest to highest priority. A higher-priority provider wins per field — except for JSON-LD and extra head fragments, which accumulate from all layers.

Priority Source Typical use
10 SEO_SUITE["DEFAULTS"] Site-wide fallback title suffix, robots, og:type
20 SEO_SUITE["SITE_DEFAULTS"][site_id] Per-site defaults on multi-site deployments
30 SeoPath row matching the request path Admin-editable overrides for specific URLs
40 Object's get_seo_metadata() or SeoObject row Model fields, or admin overrides for 3rd-party models
50 View's get_seo_metadata() / seo_* attrs View-level overrides and enhancements

Three-state fields: UNSET vs None vs a value

Each metadata field can be in one of three states:

  • UNSET: "this provider has no opinion". The resolver skips this layer and uses whatever came from a lower-priority provider.
  • None or "": "explicitly empty". This wins over lower-priority values and renders nothing. Use this when you want to suppress a field that a lower layer would otherwise emit (for example, a page that should have no meta description).
  • A string value: emitted as-is.

In practice you rarely think about this directly. The mixins and column-based providers set fields to UNSET when a model field is blank, so lower-priority values naturally survive.


Field merging rules

Field type Merge behaviour
Scalars (title, meta_description, robots, canonical_url, ...) Higher wins; UNSET defers
og dict, twitter dict Deep-merged key by key; higher wins per key
hreflang list Replaced wholesale when the higher layer provides a list
jsonld list, extra_head list Accumulated — all layers contribute

The JSON-LD accumulation rule is intentional: a view that adds a BreadcrumbList profile sits alongside a model that adds an Article profile; the page emits both <script type="application/ld+json"> blocks.


Auto-population of og / twitter fields

After merging, the finalizer fills gaps in the og and twitter dicts using the resolved page-level metadata:

  • og:titletitle (if og:title not set)
  • og:descriptionmeta_description (if not set)
  • og:urlcanonical_url (if not set)
  • og:imageog_image (if not set)
  • twitter:* fields mirror the corresponding og:* fields
  • twitter:card defaults to "summary_large_image" when an image is present, otherwise "summary"

You can override any of these by explicitly setting the og or twitter dicts at any layer.


A concrete example

Suppose the settings define a title suffix and global robots, a SeoPath row sets a custom title for /about/, and the view sets a description:

Layer 10 (DEFAULTS):  title_suffix=" | Acme",  robots="index,follow"
Layer 30 (SeoPath):   title="About Acme"
Layer 50 (View):      meta_description="We build great things."

The merged result:

title         = "About Acme"      (from layer 30)
title_suffix  = " | Acme"        (from layer 10)
meta_description = "We build great things."  (from layer 50)
robots        = "index,follow"    (from layer 10)

Rendered title tag: <title>About Acme | Acme</title>


What the resolver touches

The resolver is called once per request (memoized on the request object). It:

  1. Collects all layers in priority order.
  2. Merges them with SeoMetadata.merge_all.
  3. Absolutises the canonical URL using the request host or CANONICAL_DOMAIN.
  4. Returns a FinalizedSeoMetadata object. No UNSET sentinels remain; all fields are render-ready.

If SEO_SUITE["CACHE_TTL"] is greater than 0, the finalized result is stored in Django's cache keyed by site:language:app.model:pk:generation. The generation counter is bumped automatically on model save/delete, so stale cache entries are never served.


Reading the resolved metadata in code

If you need to inspect the resolved metadata outside a template (for example, in a REST endpoint or a management command), use resolve_seo directly:

from seo_suite.context import resolve_seo

def my_view(request):
    seo = resolve_seo(request)
    print(seo.title)
    print(seo.canonical_url)
    print(seo.jsonld)

For the common case inside a class-based view that already uses SeoViewMixin, the metadata is already in context["seo"] and on request.seo after get_context_data runs.