How to improve technical SEO on a Django website

How to improve technical SEO on a Django website

/ #Django


Django gives you a good starting point for SEO because most pages are rendered on the server, but it doesn't automatically handle things like canonical URLs, sitemaps, structured data or duplicate URLs. In this article, I'll go through the technical SEO setup I would use on a Django website and show how to implement the important parts.

Django is already a pretty good starting point

I've worked with Django for a long time, and one thing I like about it is that you don't need to do anything unusual to make your content readable by search engines. A normal Django template renders HTML on the server, so Google doesn't have to execute a large JavaScript application before it can see the page.

That doesn't mean a new Django project has good technical SEO automatically. Django doesn't know which pages you want indexed, which URL should be canonical, what your meta descriptions should say or whether five slightly different URLs are showing the same content.

Most of the work is fairly small once you know where to put it. I normally start with the HTML <head>, then work through URLs, sitemaps, crawling, HTTP status codes and performance.

Give every important page its own title and description

The first thing I would fix is the page title. It's common to see Django projects where every page inherits the same <title> from base.html. That works technically, but it doesn't tell Google or the person looking at the search results much about the individual page.

I normally create blocks in the base template:

<head>
    <title>
        {% block title %}
            Code With Stein
        {% endblock %}
    </title>

    {% block meta_description %}
    {% endblock %}
</head>

Then an article template can override both:

{% block title %}
    {{ article.title }} | Code With Stein
{% endblock %}

{% block meta_description %}
    <meta
        name="description"
        content="{{ article.meta_description }}"
    >
{% endblock %}

I prefer storing a separate meta description instead of blindly cutting the first 160 characters from the article. The first sentence of an article isn't always a good description of the page.

I would also make the title field and meta description editable in Django admin. That makes it possible to change how an article appears in search without changing the article itself.

The title should describe the page rather than trying to fit every possible keyword into it. Google can also generate its own title or snippet when it thinks something else fits the search better, so stuffing more text into these fields doesn't give you more control.

Add canonical URLs

Canonical URLs are one of the first things I check on a website that has been running for a while. The same page can sometimes be reached through more than one URL because of query parameters, old URLs, HTTP versus HTTPS or different domain variants.

For example, these URLs could theoretically return the same article:

https://example.com/articles/django-seo/
https://www.example.com/articles/django-seo/
https://example.com/articles/django-seo/?ref=twitter

I want one URL to be the main version.

Add a place for the canonical URL in base.html:

{% if canonical_url %}
    <link
        rel="canonical"
        href="{{ canonical_url }}"
    >
{% endif %}

Then build the canonical URL in the view:

from django.shortcuts import get_object_or_404, render
from django.urls import reverse


def article_detail(request, slug):
    article = get_object_or_404(
        Article,
        slug=slug,
        is_published=True,
    )

    path = reverse(
        "articles:detail",
        kwargs={"slug": article.slug},
    )

    canonical_url = request.build_absolute_uri(path)

    return render(
        request,
        "articles/detail.html",
        {
            "article": article,
            "canonical_url": canonical_url,
        },
    )

Notice that I'm building the URL from the Django route rather than simply using the complete current request URL. If somebody opens the page with ?ref=twitter, I don't want that query string appearing in the canonical tag.

I also like self-referencing canonicals. In other words, the main version of the article points to itself as canonical instead of only adding canonical tags to duplicate versions.

Pick one URL format and stick to it

A canonical tag helps, but I'd rather avoid unnecessary duplicate URLs in the first place. Pick whether the site uses www.example.com or example.com, force HTTPS and use one consistent rule for trailing slashes.

Django uses trailing slashes in most of its examples, and APPEND_SLASH is enabled by default when CommonMiddleware is installed. If your routes use trailing slashes, keep them consistent:

path(
    "articles/<slug:slug>/",
    views.article_detail,
    name="detail",
)

I wouldn't create another route for the exact same content without the slash.

The same applies to your domain. If you've decided that https://example.com is the real domain, requests for http://example.com and https://www.example.com should permanently redirect there.

I normally handle that at Nginx or the load balancer rather than inside Django. It means Django only has to deal with the URLs I actually want visitors and search engines to use.

Make sure Django knows the request is HTTPS

If you're running Django behind Nginx, Nginx will often handle HTTPS and communicate with Gunicorn over HTTP internally. Django therefore needs to know that the original visitor used HTTPS.

In the deployment setup I've used in my other articles, Nginx sends:

proxy_set_header X-Forwarded-Proto $scheme;

And Django can be configured with:

SECURE_PROXY_SSL_HEADER = (
    "HTTP_X_FORWARDED_PROTO",
    "https",
)

This matters outside SEO as well, but it also prevents you from accidentally generating http:// URLs when the public website actually uses HTTPS.

You should only enable this when you control the proxy in front of Django and know that it sets the header correctly.

Create a sitemap with Django

Django already includes a sitemap framework, so there's no reason to manually generate an XML file every time you publish an article.

Add the sitemap framework to your project:

INSTALLED_APPS = [
    ...

    "django.contrib.sites",
    "django.contrib.sitemaps",
]

SITE_ID = 1

If you're adding the sites framework for the first time, run your migrations and configure the domain in Django admin.

Then create sitemaps.py inside your articles app:

from django.contrib.sitemaps import Sitemap

from .models import Article


class ArticleSitemap(Sitemap):
    def items(self):
        return Article.objects.filter(
            is_published=True,
        )

    def lastmod(self, article):
        return article.updated_at

    def location(self, article):
        return article.get_absolute_url()

Your model could provide the URL like this:

from django.urls import reverse


class Article(models.Model):
    # fields...

    def get_absolute_url(self):
        return reverse(
            "articles:detail",
            kwargs={"slug": self.slug},
        )

Then register the sitemap in your main urls.py:

from django.contrib.sitemaps.views import sitemap
from django.urls import path

from articles.sitemaps import ArticleSitemap


sitemaps = {
    "articles": ArticleSitemap,
}


urlpatterns = [
    # Other URLs...

    path(
        "sitemap.xml",
        sitemap,
        {"sitemaps": sitemaps},
        name="sitemap",
    ),
]

You should now be able to open:

https://example.com/sitemap.xml

And see your published articles.

I deliberately left out priority and changefreq. Google ignores those values, while an accurate lastmod value can be useful. Make sure updated_at represents a real change to the page rather than being updated every time an unrelated background process touches the database row.

Add a robots.txt file

A small robots.txt is useful for telling crawlers which parts of the site they don't need to crawl and where the sitemap can be found.

You can serve it directly from Django:

from django.http import HttpResponse


def robots_txt(request):
    content = [
        "User-agent: *",
        "Disallow: /admin/",
        "",
        "Sitemap: "
        + request.build_absolute_uri("/sitemap.xml"),
    ]

    return HttpResponse(
        "\n".join(content),
        content_type="text/plain",
    )

Then add:

path(
    "robots.txt",
    views.robots_txt,
    name="robots_txt",
)

There is one distinction here that causes a lot of confusion: robots.txt controls crawling. It isn't a reliable way to remove a page from Google.

If you need a page to stay private, require authentication. If the page is public but shouldn't appear in search, use noindex.

Use noindex on pages that shouldn't be in search

Not every Django view deserves its own Google result. Internal search pages, temporary pages and some filtered listings are common examples.

For those pages, I normally add:

<meta
    name="robots"
    content="noindex, follow"
>

You can make that configurable in your base template:

{% if noindex %}
    <meta
        name="robots"
        content="noindex, follow"
    >
{% endif %}

Then the view decides when it is needed:

return render(
    request,
    "search/results.html",
    {
        "results": results,
        "noindex": True,
    },
)

Don't block that URL in robots.txt at the same time. Google needs to crawl the page before it can see the noindex instruction.

Return real 404 responses

A page that doesn't exist should return HTTP 404. It sounds obvious, but I've seen sites return a friendly "article not found" page with HTTP 200, which makes it look like a real page to crawlers.

Django makes this easy:

article = get_object_or_404(
    Article,
    slug=slug,
    is_published=True,
)

If the article doesn't exist, Django returns a proper 404 response.

You can still create a custom 404.html template so the page looks like the rest of your website. The design doesn't change the fact that the response code should remain 404.

Redirect old URLs instead of deleting them

URLs tend to change as a website grows. Maybe you rename a category, change an article slug or move a page somewhere else.

If an old URL has links pointing to it or already appears in Google, I don't want it to suddenly become a 404 if there's a clear replacement. I'll redirect it permanently instead.

Django can do that with:

from django.shortcuts import redirect


def old_article(request):
    return redirect(
        "articles:detail",
        slug="new-article-slug",
        permanent=True,
    )

permanent=True returns a permanent redirect rather than a temporary one.

For a larger site where slugs change regularly, I would probably store previous slugs or use a redirect table instead of hardcoding every redirect in urls.py.

Be careful with filters and query parameters

Query parameters can create hundreds or thousands of URL variations without you noticing. A product listing might support URLs like:

/products/?sort=price
/products/?sort=name
/products/?color=blue
/products/?color=blue&sort=price

Sometimes those pages are useful search pages. Often they're simply different views of the same products.

I decide explicitly which versions should be indexable. If a filtered page has no reason to appear independently in Google, I'll normally keep it out of the sitemap and either use a canonical URL or noindex, depending on what the page represents.

The important part is not to let every possible combination become an accidental SEO landing page.

Add structured data where it matches the page

If your Django site contains articles, products, events or other content supported by structured data, you can generate the JSON-LD directly from your Django template.

For an article, a simplified version could look like this:

<script type="application/ld+json">
{
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "{{ article.title|escapejs }}",
    "datePublished": "{{ article.published_at|date:'c' }}",
    "dateModified": "{{ article.updated_at|date:'c' }}",
    "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "{{ canonical_url }}"
    }
}
</script>

You can add the author, image and other relevant fields if that information is actually present on the page.

I wouldn't add every schema type I can find simply because it exists. Structured data should describe the visible content accurately. Google recommends JSON-LD, and its Rich Results Test is useful for checking the generated markup before deploying it.

Make important pages reachable through normal links

A sitemap helps search engines find pages, but I still want the site itself to have sensible internal links. Important articles shouldn't only be reachable by typing the URL or using the search box.

Normal Django links are exactly what we want:

<a href="{% url 'articles:detail' article.slug %}">
    {{ article.title }}
</a>

Use category pages, related articles, navigation and other relevant links so important content can be reached by following normal <a href> elements.

This is also one reason I don't like using JavaScript click handlers for ordinary navigation when a regular link does the job.

Improve the response time of Django pages

Technical SEO isn't only about tags in the HTML. A slow Django view still produces a slow website.

The first place I usually look is the database. A page that makes 150 queries because of an N+1 problem isn't going to become fast because you minified the CSS.

Django's select_related() and prefetch_related() solve many of these problems:

articles = (
    Article.objects
    .filter(is_published=True)
    .select_related("author")
    .prefetch_related("categories")
)

For pages that don't change very often, Django's cache framework can also remove a lot of unnecessary work.

For example:

from django.views.decorators.cache import cache_page


@cache_page(60 * 15)
def article_list(request):
    ...

That caches the response for 15 minutes. I wouldn't add caching blindly to pages with user-specific information, but public article and category pages are often good candidates.

Don't make Django serve everything

In production, I normally put Nginx in front of Gunicorn. Nginx handles static files, while Gunicorn spends its time running Django.

Images, CSS and JavaScript can also be served through object storage or a CDN if the site grows. The exact setup matters less than making sure every static request doesn't need to travel through Django.

Compression, browser caching and sensible image sizes can make a much bigger difference to the finished page than changing Python code.

Pay attention to images

Large images are an easy way to make an otherwise fast page feel slow. If the image is displayed at 800 pixels wide, I don't need to send a 6000-pixel original from a camera.

I also set width and height where possible:

<img
    src="{{ article.image.url }}"
    alt="{{ article.image_alt }}"
    width="1200"
    height="675"
>

Those dimensions help the browser reserve space before the image loads, which reduces layout movement.

Images further down the page can usually be lazy loaded:

<img
    src="{{ image.url }}"
    alt="{{ image.alt }}"
    loading="lazy"
    width="800"
    height="450"
>

I wouldn't automatically add loading="lazy" to the main image at the top of the page. If that image is the largest visible element when the page loads, delaying it can make the page appear slower.

Keep CSS and JavaScript under control

Django can't save you from a frontend that downloads several megabytes of JavaScript before the visitor can use the page. If a page doesn't need JavaScript, I don't add it just because the site has a build system available.

I also try to avoid loading scripts globally when they're only used on one page. A chart library needed on the analytics page doesn't need to be downloaded by somebody reading a blog article.

Core Web Vitals are useful here because they give you something measurable to work with instead of guessing whether the site "feels fast".

Test what Django actually sends

One mistake I see quite often is checking the Django template and assuming that means Google receives the same thing.

Open the finished page and inspect the generated source. Check the title, description, canonical tag, robots tag and structured data. Then check the HTTP response code.

You can do some of that from the terminal:

curl -I https://example.com/articles/django-seo/

For a normal article, I expect something like:

HTTP/2 200

For an old URL that moved permanently:

HTTP/2 301

And for a URL that doesn't exist:

HTTP/2 404

These checks are simple, but they catch a surprising number of problems.

Use Search Console after deploying

Once the changes are live, I add the website to Google Search Console and submit /sitemap.xml. I also put the sitemap URL in robots.txt so crawlers can discover it there.

The URL Inspection tool is useful when checking individual pages. It shows whether Google can access the page, whether it is indexable and which canonical URL Google selected.

I also check the Page Indexing report rather than assuming that every URL in the sitemap has been indexed. A sitemap tells Google about URLs; it doesn't force Google to index them.

If I were improving an existing Django site, this is where I would start. I would look at the URLs Google already knows about, fix the duplicate and broken ones, make the important pages easy to crawl, and then work on performance. Most technical SEO problems are easier to fix when you can see the exact URL and response that Google is dealing with.

Comments

No comments yet...

Add comment

Please log in to comment.

Newsletter

Subscribe to my weekly newsletter. One time per week I will send you a short summary of the tutorials I have posted in the past week.