Updated 2026-10-26

Hugo SEO: meta descriptions, Open Graph, the sitemap and baseURL

Hugo writes plain static HTML, so crawlers see everything you put in your templates. What ends up in <head> is up to your theme: Hugo ships embedded templates for Open Graph, X cards and schema.org microdata, but it doesn't add a meta description or a canonical link on its own. This guide goes through what the embedded templates do, what they leave out and the settings that break absolute URLs, for current Hugo releases (Hugo: embedded templates).

1. baseURL first

Every absolute URL Hugo writes (.Permalink, og:url, og:image, sitemap entries) is built from baseURL. Set it to the production address, with a trailing slash:

# hugo.toml
baseURL = 'https://docs.example.com/'
title = 'Example Docs'
[params]
  description = 'Reference and guides for the Example CLI.'
  images = ['og/default.png']

The usual ways it goes wrong:

Check the built HTML, not the config: grep -o 'property="og:url" content="[^"]*"' public/index.html should print your production address.

2. Description and canonical: add them to your head

None of Hugo's embedded templates writes <meta name="description"> or <link rel="canonical">. Many themes add them, many don't. Look at public/index.html after a build; if they're missing, add them to your head partial (often layouts/_partials/head.html, or the theme's equivalent copied into your own layouts):

<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ with .Summary }}{{ . | plainify | htmlUnescape }}{{ else }}{{ site.Params.description }}{{ end }}{{ end }}">
<link rel="canonical" href="{{ .Permalink }}">

Then give each page its own description in front matter:

---
title: Configure the CLI
description: Every option in the config file, with defaults and an example for monorepos.
images: ['og/configure.png']
---

How the .Summary fallback behaves

Without description, the snippet above (and the embedded templates) use .Summary. Hugo picks it from the summary front matter field, then the content before a <!--more--> divider, then the first summaryLength words (70 by default) (summaries). On a docs page that first 70 words is often a note, a list of prerequisites or the start of a code sample, and it's longer than the 150 or so characters Google shows. List and taxonomy pages usually have no content of their own, so they fall through to site.Params.description, and all of them share it.

3. The embedded Open Graph, X and schema templates

Add the three partials to your head:

{{ partial "opengraph.html" . }}
{{ partial "twitter_cards.html" . }}
{{ partial "schema.html" . }}

Older themes call them as {{ template "_internal/opengraph.html" . }}; current docs only show the partial form, so use that in new code. What they write:

Where the image comes from

Both templates look for images in this order (from the embedded _funcs/get-page-images partial):

  1. The page's images front matter.
  2. If that's not set, a page resource whose name contains feature, then one containing cover or thumbnail. This only works for page bundles (content/post/my-post/index.md with the image next to it).
  3. If neither found anything, the first entry in params.images in your site config.

Each entry can be a page resource, a file in assets/, a path in static/ (made absolute from baseURL) or a full URL. Use a PNG or JPG around 1200×630; previewers don't render SVG. If a card still has no picture, og:image not showing covers the other causes.

4. Keeping pages out of search

Hugo has no built-in robots tag. A common pattern is a front matter flag and a line in your head:

{{ with .Params.noindex }}<meta name="robots" content="noindex">{{ end }}

Pair it with sitemap: {disable: true} in the same page's front matter, so you're not listing a page you've asked Google to drop. For whole page kinds you don't want at all (often taxonomy terms on a docs site), disableKinds = ['taxonomy', 'term'] stops Hugo from building them.

5. The sitemap

Hugo writes sitemap.xml by default (sitemap templates). A multilingual site gets one per language plus a sitemapindex.xml at the root.

# hugo.toml
enableGitInfo = true
enableRobotsTXT = true
[sitemap]
  filename = 'sitemap.xml'

6. Check the build

Hugo leaves old files in public/ when pages are renamed or deleted, so build into a clean folder before you check or deploy: hugo --gc --minify --cleanDestinationDir, or delete public/ first. Then:

grep -rL --include='*.html' 'name="description"' public
grep -rhoE --include='*.html' '<meta name="description" content="[^"]*"' public | sort | uniq -c | sort -rn | head

The first lists pages without a description, the second shows descriptions shared by several pages. Duplicate titles and descriptions on a docs site goes through fixing those. To see the result as Google and the link previewers do, run a few deployed pages through the free SEO checker, or up to 50 pages from your sitemap through the site checker.

Automating it with Headlark

Hugo's templates fill every page from the same fallbacks, so the result is a truncated first paragraph or the site description. Headlark's build step runs after hugo on the HTML in public/ and writes those tags from each page's content. It's a Node.js CLI, so the build environment needs Node 20 or newer next to Hugo; GitHub Actions, Netlify and Cloudflare Pages all have it. Add it once with npm i -D headlark (that creates a package.json and a lockfile, even in a repo with no other JavaScript), then:

hugo --gc --minify --cleanDestinationDir
npx headlark public --site-url https://docs.example.com/

Use the same address as baseURL, including any path. Set HEADLARK_KEY in the build environment. On GitHub Pages, add the npx headlark step between the Hugo build and the upload, as in GitHub Pages (Actions); on Netlify, the build plugin runs it for you on the publish directory.

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.