A lightweight, extensible SEO metadata layer for Django: titles, meta tags, canonical URLs, hreflang, JSON-LD schema and Open Graph for models and views.
Project description
django-seo-suite
A lightweight, extensible SEO metadata layer for Django. It gives any project
one consistent way to attach and render the full set of SEO attributes —
<title>, <h1>, meta description/keywords/robots, canonical URL, hreflang
alternates, JSON-LD schema and Open Graph / Twitter cards — across models,
class-based views (including listing and model-less views), and even third-party
models you can't edit.
- No required apps. The core ships a single migration (the versioned
robots.txttable) and works withoutdjango.contrib.sites(detected at runtime). - One dependency: Django (4.2+). Python 3.10+.
- Multi-provider resolver. Metadata is merged from several sources by a clear precedence, then rendered by template tags.
- Built for extension. A separate package can add capability through stable, documented hooks — no forking, no monkeypatching.
Install
pip install django-seo-suite
# settings.py
INSTALLED_APPS = [
# ...
"seo_suite",
# optional, admin-editable overrides:
"seo_suite.contrib.seopath", # SEO keyed by URL path
"seo_suite.contrib.seoobject", # SEO for third-party models (uses ContentTypes)
]
TEMPLATES = [{
# ...
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"seo_suite.context.seo", # enables {% seo_head %} everywhere
]},
}]
Then run migrations (the core app ships one migration for the robots.txt table):
python manage.py migrate
Quickstart
On a model you own:
from django.db import models
from seo_suite.mixins import SeoModelMixin
class Article(SeoModelMixin, models.Model):
title = models.CharField(max_length=200)
summary = models.TextField()
cover = models.ImageField(upload_to="covers/")
slug = models.SlugField()
SEO_FIELD_MAP = {"meta_description": "summary", "og_image": "cover"}
SEO_SCHEMA_PROFILES = ["Article", "BreadcrumbList"]
def get_absolute_url(self):
return f"/articles/{self.slug}/"
title/description/image fall back to conventional field names automatically;
SEO_FIELD_MAP overrides them; get_absolute_url becomes the canonical URL.
On a view:
from django.views.generic import DetailView, ListView
from seo_suite.mixins import SeoViewMixin, SeoListViewMixin
class ArticleDetail(SeoViewMixin, DetailView):
model = Article
# the object's metadata is used automatically; view attrs override it
class ArticleList(SeoListViewMixin, ListView): # model-less views work too
model = Article
seo_title = "All Articles" # becomes "All Articles – Page 2" when paginated
In your base template:
{% load seo_suite %}
<head>
{% seo_head %} {# title, meta, canonical, hreflang, OG, Twitter, JSON-LD #}
</head>
Need finer control? Use the granular tags: {% seo_title %}, {% seo_meta %},
{% seo_canonical %}, {% seo_hreflang %}, {% seo_opengraph %},
{% seo_twitter %}, {% seo_jsonld %}, {% seo_extra_head %}. Every partial in
templates/seo_suite/ is overridable by shipping your own.
How resolution works
Metadata is merged from lowest to highest precedence (higher wins per field; JSON-LD and extra-head fragments accumulate):
| Precedence | Source |
|---|---|
| 10 | SEO_SUITE["DEFAULTS"] (global) |
| 20 | SEO_SUITE["SITE_DEFAULTS"][site_id] |
| 30 | seopath.SeoPath row matching the URL path (optional app) |
| 40 | the object's get_seo_metadata() (model mixin) or seoobject row |
| 50 | the view's get_seo_metadata() / seo_* attributes |
A field left "unset" defers to lower precedence; an explicit None/"" wins and
renders nothing.
JSON-LD schema library
Enable ready-made schema.org profiles declaratively — no template editing:
class FaqPage(SeoModelMixin, models.Model):
SEO_SCHEMA_PROFILES = ["WebPage", "FAQPage"]
def get_faqs(self):
return [{"question": "…", "answer": "…"}]
Free profiles: WebPage, WebSite, Organization, Person, BreadcrumbList,
Article, FAQPage, Product. An object can supply or override fields via
get_schema_data(key, context).
Sitemaps & hreflang
from seo_suite.sitemaps import SeoSitemap
class ArticleSitemap(SeoSitemap):
i18n = True
alternates = True
x_default = True
def items(self):
return Article.objects.all()
SeoSitemap sets each <loc> to the page's resolved canonical, so the sitemap
URL always matches rel="canonical". For on-page alternates,
seo_suite.hreflang.build_hreflang_alternates(path, request) produces the same
LANGUAGES-driven set the sitemap uses.
Versioned robots.txt
Serve robots.txt from the database with a full version history, so a traffic
regression can be traced to a specific change. Add the route to your root urlconf:
# urls.py
urlpatterns = [
# ...
path("", include("seo_suite.urls")), # serves /robots.txt
]
Then manage versions in the Django admin. Adding a version (the form is pre-filled
with the current live content) publishes it on save; the version it replaces is
kept as a read-only history row. To roll back, run the "Roll back" action on an
earlier version. Exactly one version is live per site, enforced at the database
level. Every version records created_at, activated_at, and the author, so the
admin changelist is a timeline you can line up against your analytics. When no
version is active, the view serves SEO_SUITE["ROBOTS_TXT_FALLBACK"];
SEO_SUITE["ROBOTS_SITEMAP_URLS"] are appended as Sitemap: lines.
Settings (SEO_SUITE)
| Key | Default | Purpose |
|---|---|---|
RESOLVER_CLASS |
seo_suite.resolver.Resolver |
swap the resolver |
SITE_ID_RESOLVER |
None |
custom current-site callable |
DEFAULTS |
{robots, og.type, ...} |
global metadata seed |
SITE_DEFAULTS |
{} |
per-site metadata seed |
CANONICAL_DOMAIN / FORCE_HTTPS_CANONICAL |
None / True |
canonical absolutization |
LIST_CANONICAL_INCLUDES_PAGE |
True |
paginated-list canonical policy |
HREFLANG_X_DEFAULT |
True |
emit x-default |
CACHE_TTL |
0 |
cache resolved payload (0 = off) |
OBJECT_MODELS |
[] |
allowlist "app.model" for seoobject |
DEFAULT_SCHEMA_PROFILES |
[] |
profiles applied site-wide |
ROBOTS_TXT_FALLBACK |
"User-agent: *\nAllow: /" |
served when no robots.txt version is active |
ROBOTS_SITEMAP_URLS |
[] |
URLs appended to robots.txt as Sitemap: lines |
ROBOTS_CACHE_TTL |
0 |
cache served robots.txt (0 = off) |
Extending the suite (public contract)
These are versioned, stable APIs an extension package may rely on:
- Registries —
provider_registry.register(provider, priority=…),renderer_registry.register(name, callable). - Swappable classes —
SEO_SUITE["RESOLVER_CLASS"],["JSONLD_SERIALIZER"]. - Signals —
seo_metadata_resolved(mutation allowed),seo_cache_invalidate,seo_head_rendering. - Schema profiles —
schema_registry.register(profile)with namespaced keys. - Abstract models —
AbstractSeoPath,AbstractSeoObject,SeoColumnsMixin. - Autodiscovery — ship a top-level
seo_extensions.py(imported at startup) or publish an entry point in theseo_suite.extensionsgroup. - Templates — override any
templates/seo_suite/*partial.
Everything imports from one surface:
# my_extension/seo_extensions.py
from seo_suite.extension import PRECEDENCE_PATH, Provider, SeoMetadata, provider_registry
class MyProvider(Provider):
priority = 35
def provide(self, context):
return SeoMetadata.partial(meta_description="…")
provider_registry.register(MyProvider())
Development
pip install -e ".[dev]"
pytest # contrib apps on, sites off
SEO_SUITE_TEST_SITES=1 pytest # sites framework on
SEO_SUITE_TEST_NO_CONTRIB=1 pytest # core only
SEO_SUITE_TEST_PRO=1 pytest # with a fake extension package
ruff check seo_suite tests
tox # full Python × Django matrix
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_seo_suite-0.1.1.tar.gz.
File metadata
- Download URL: django_seo_suite-0.1.1.tar.gz
- Upload date:
- Size: 50.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c66fb1c19345ad2681d2eaef99bf98ed413fbca887650928cd1498187b63657a
|
|
| MD5 |
c1884037bc3c7d4c16d787acafd3aa21
|
|
| BLAKE2b-256 |
5bd567bda31412be2c9fd86ddb21a7b71498c8587d3c171d90cb8c17c7c65b5f
|
Provenance
The following attestation bundles were made for django_seo_suite-0.1.1.tar.gz:
Publisher:
publish.yml on specivo/django-seo-suite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_seo_suite-0.1.1.tar.gz -
Subject digest:
c66fb1c19345ad2681d2eaef99bf98ed413fbca887650928cd1498187b63657a - Sigstore transparency entry: 1739187588
- Sigstore integration time:
-
Permalink:
specivo/django-seo-suite@f14bfe31d14108a27afaec739b36300f33f44307 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/specivo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f14bfe31d14108a27afaec739b36300f33f44307 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_seo_suite-0.1.1-py3-none-any.whl.
File metadata
- Download URL: django_seo_suite-0.1.1-py3-none-any.whl
- Upload date:
- Size: 51.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d2c74ddd75883952b750bec1647f9b0dbcfec2f816e42a6b5fa0017bf3483141
|
|
| MD5 |
76d28128b3f6cf0e6768787e9863d9f3
|
|
| BLAKE2b-256 |
e34355dcf6720adbba76530a4f2f57a7d58d2691d07f57db3518a84784f73187
|
Provenance
The following attestation bundles were made for django_seo_suite-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on specivo/django-seo-suite
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_seo_suite-0.1.1-py3-none-any.whl -
Subject digest:
d2c74ddd75883952b750bec1647f9b0dbcfec2f816e42a6b5fa0017bf3483141 - Sigstore transparency entry: 1739187595
- Sigstore integration time:
-
Permalink:
specivo/django-seo-suite@f14bfe31d14108a27afaec739b36300f33f44307 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/specivo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f14bfe31d14108a27afaec739b36300f33f44307 -
Trigger Event:
release
-
Statement type: