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:
- Missing trailing slash. On a site under a path,
https://example.com/docsbuilds broken URLs. Writehttps://example.com/docs/. - Overridden in CI.
--baseURLandHUGO_BASEURLwin over the config file. The official GitHub Pages workflow passes--baseURL "${{ steps.pages.outputs.base_url }}/", which is right only if Pages knows your custom domain. On Netlify,hugo -b $DEPLOY_PRIME_URLis for deploy previews; don't let it run on production with a preview address. relativeURLsorcanonifyURLs. Both are legacy options. Preview images and canonicals have to be absolute, so leave them off.
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:
- Title: the page's
.Title, falling back to the site title.og:site_nameis the site title. - Description:
or .Description .Summary site.Params.description, the same chain as above, as plain text. og:url:.Permalink, so it's only as good asbaseURL.og:type:articlefor regular pages, witharticle:published_time,article:modified_timeand up to sixarticle:tagfrom your tags;websitefor everything else.- X card:
summary_large_imagewhen the page has an image, otherwisesummary.twitter:sitecomes fromparams.social.twitter. - schema.html writes microdata
<meta itemprop>tags (name, description, dates, word count, images, keywords), not JSON-LD. If you want rich results, add JSON-LD yourself; JSON-LD for Astro and Docusaurus covers which types fit docs and blog pages, and the JSON is the same in a Hugo partial.
Where the image comes from
Both templates look for images in this order (from the embedded _funcs/get-page-images partial):
- The page's
imagesfront matter. - If that's not set, a page resource whose name contains
feature, then one containingcoverorthumbnail. This only works for page bundles (content/post/my-post/index.mdwith the image next to it). - If neither found anything, the first entry in
params.imagesin 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'
lastmodis written only when a page has a non-zero.Lastmod.enableGitInfo = truefills it from each file's last commit; in CI that needs the full history (fetch-depth: 0). Google useslastmodwhen it's accurate and ignoreschangefreqandpriority, so don't bother setting those.sitemap: {disable: true}in front matter leaves one page out.enableRobotsTXTwrites arobots.txtthat allows everything. To point it at the sitemap, override it withlayouts/robots.txtcontainingUser-agent: *andSitemap: {{ "sitemap.xml" | absURL }}.
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.
- 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.
- An existing
og:image, fromimages, a bundle's cover orparams.images, is kept. Headlark doesn't create images; with onlyparams.imagesset, every page keeps that one image. - Your canonical, robots tags and JSON-LD types are kept. The schema.html microdata isn't touched.
- Build into a clean
public/: Headlark optimizes every HTML file it finds there, including stale ones Hugo left behind, and each counts as a page.
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.