Updated 2026-10-26
MkDocs SEO: site_url, meta descriptions, social cards and the sitemap
MkDocs builds a static HTML page per Markdown file, so crawlers and link previewers get real content. With Material for MkDocs, the theme most MkDocs sites use, each page also gets a <title>, a meta description and, once you set site_url, a canonical link and a sitemap with real URLs. Open Graph tags come only from the social plugin or a template override. This guide goes through each setting for MkDocs 1.6 and Material 9 (MkDocs: configuration, Material for MkDocs).
Material for MkDocs has been in maintenance mode since November 2025: no new features, critical fixes through May 2027, and its team now builds Zensical, which reads existing mkdocs.yml files. Everything below works on current Material releases; check the plugin list before moving to Zensical.
1. site_url, site_name and site_description
# mkdocs.yml
site_name: Example Docs
site_url: https://docs.example.com/
site_description: Reference and guides for the Example CLI.
theme:
name: material
site_urlis the one setting not to skip. Without it there's no<link rel="canonical">on any page, the sitemap has no URLs in it, and the social plugin generates cards but doesn't link them. If the site lives under a path, include it:https://example.github.io/repo/.site_nameis appended to every title: Material renders<title>Page title - Example Docs</title>, and just the site name on the home page.site_descriptionbecomes the meta description of every page that doesn't set its own. That's a reasonable fallback and also the reason MkDocs sites show up in Search Console with hundreds of duplicate descriptions.
2. Per-page title and description
Material reads the page's YAML front matter (MkDocs calls it meta-data):
---
title: Configure the CLI
description: Every option in the config file, with defaults and an example for monorepos.
---
# Configuration
descriptionreplacessite_descriptionin that page's<meta name="description">, and the social plugin uses it on the card.titlesets the<title>(still followed by- site_name). Without it, the title comes from the nav entry inmkdocs.ymlor the page's first heading. That's how ten pages called "Overview" or "Usage" end up with the same title.
To see which pages still use the fallback, count descriptions in the build:
grep -rhoE --include='*.html' '<meta name="description" content="[^"]*"' site | sort | uniq -c | sort -rn | head
Duplicate titles and descriptions on a docs site goes through finding and fixing them.
3. Link previews with the social plugin
Material's social plugin draws a 1200×630 card image for every page from its title and description and adds the tags that use it (social plugin):
plugins:
- search
- social:
cards_layout_options:
background_color: "#1f2937"
color: "#ffffff"
It writes og:type, og:title, og:description, og:url, og:image with its type and size, and twitter:card set to summary_large_image with matching twitter: title, description and image. Things to know:
- It ships with Material, but drawing the images needs Cairo and the Python imaging libraries:
pip install "mkdocs-material[imaging]"plus the system packages from Material's image processing guide. CI images often lack Cairo, so a build that works locally fails there. - Without
site_urlit logs a warning and writes no tags at all. - Cards are cached in
.cache/plugin/social. Cache that folder in CI, or every build redraws every card. cards: falseturns image generation off, for example in a fast local build.
4. Open Graph tags without the plugin
If you can't install Cairo, or want one fixed image, add the tags in a theme override (customization). Set custom_dir:
theme:
name: material
custom_dir: overrides
and extend the extrahead block in overrides/main.html:
{% extends "base.html" %}
{% block extrahead %}
{% set desc = page.meta.description if page.meta and page.meta.description else config.site_description %}
<meta property="og:type" content="website">
<meta property="og:title" content="{{ page.title | striptags }}">
<meta property="og:description" content="{{ desc }}">
{% if page.canonical_url %}<meta property="og:url" content="{{ page.canonical_url }}">{% endif %}
<meta property="og:image" content="{{ config.site_url }}assets/og-default.png">
<meta name="twitter:card" content="summary_large_image">
{% if page.meta and page.meta.robots %}<meta name="robots" content="{{ page.meta.robots }}">{% endif %}
{% endblock %}
Put a PNG or JPG around 1200×630 at docs/assets/og-default.png; previewers don't render SVG. The last line lets a page opt out of search with robots: noindex in its front matter, which Material doesn't support on its own. If a card still has no picture, og:image not showing covers the other causes.
5. use_directory_urls
With the default use_directory_urls: true, docs/setup.md is written to site/setup/index.html and linked as /setup/, and that's the URL in the canonical and the sitemap. Leave it on. MkDocs suggests false (which writes setup.html) only for docs opened from the file system or hosts that can't serve index.html for a folder. Switching it on a live site changes every URL, so old links and the URLs Google has indexed break unless you add redirects, for example with the mkdocs-redirects plugin.
6. The sitemap and excluded pages
MkDocs writes sitemap.xml and sitemap.xml.gz on every build, with nothing to configure beyond site_url. Two details:
lastmodis the build date for every page, not the date the page changed. Google ignores alastmodthat's always today, so don't expect it to speed up recrawls.- Every built page is listed, including pages hidden from the nav with
not_in_nav. To keep a page out, don't build it:exclude_docsskips files entirely, anddraft_docs(MkDocs 1.6) shows drafts inmkdocs servebut leaves them out ofmkdocs build.
Add Sitemap: https://docs.example.com/sitemap.xml to docs/robots.txt and submit the sitemap in Search Console.
7. Check the build
Run mkdocs build --strict (warnings fail the build, so broken links don't ship), then check site/:
grep -rL --include='*.html' 'rel="canonical"' site
grep -rL --include='*.html' 'property="og:image"' site
The first list should be empty once site_url is set. Then check a few deployed pages with the free SEO checker, which shows the Google result and the LinkedIn, Slack and X cards, or up to 50 sitemap pages with the site checker.
Automating it with Headlark
The settings above take an afternoon. A description for each of several hundred pages is what never gets written. Headlark's build step runs after mkdocs build on the HTML in site/ and writes them from each page's content. It's a Node.js CLI, so the build environment needs Node 20 or newer alongside Python; GitHub Actions, Netlify and Cloudflare Pages have it. Add it once with npm i -D headlark (that creates a package.json and a lockfile next to mkdocs.yml), then:
mkdocs build
npx headlark site --site-url https://docs.example.com/
Use the same address as site_url. Set HEADLARK_KEY in the build environment. On GitHub Pages, run both in an Actions workflow and upload site, as in GitHub Pages (Actions); mkdocs gh-deploy builds and pushes in one step, which leaves no point to run Headlark in between. On Read the Docs, add nodejs: "22" under build.tools in .readthedocs.yaml, run npm ci in build.jobs.post_install, and run npx headlark $READTHEDOCS_OUTPUT/html --site-url … in build.jobs.post_build, with HEADLARK_KEY as a project environment variable.
- Each page gets a title, a description, Open Graph and X tags written from its own content, and alt text for images that have none, replacing
site_descriptionwherever a page fell back to it. - An existing
og:image, such as a social plugin card, is kept. Headlark doesn't create images, so the social plugin and Headlark work well together: the plugin draws the card, Headlark writes the text. - Your canonical and robots tags are kept.
Try it
The free plan covers one site and 25 pages. Add your site, copy its secret key, and your next build ships with SEO.