Hugo SEO: what the templates must do, since nothing is automatic
You run Hugo. SEO only happens if your templates do it. Here is what to put in the head partial, the sitemap and robots templates. How to handle taxonomy pages, canonicals and images. A checklist to ship with confidence.
By Théophile Louvart, founder of Porteur · Updated 14 September 2026 · Markdown
What good Hugo SEO looks like
Hugo is fast. It does not do SEO for you. The theme decides, or you write the templates.
- The head partial sets titles, descriptions, canonicals and robots meta.
- The sitemap.xml is generated and scoped to indexable URLs.
- robots.txt is explicit and safe in staging.
- Taxonomy pages do not bloat the index.
- Images have alt text, correct sizes and fast delivery.
You control this in layouts, partials and config. Nothing is automatic beyond what your theme already ships, as of 2026.
The head partial: titles, descriptions, canonicals and robots
Put all metadata in one partial. Call it from every layout. Keep logic simple and predictable.
{{/* layouts/partials/head.html */}}
<title>{{ with .Params.seo_title }}{{ . }}{{ else }}{{ .Title }}{{ if ne .Kind "home" }} | {{ .Site.Title }}{{ end }}{{ end }}</title>
{{ with or .Params.meta_description .Summary }}
<meta name="description" content="{{ . | plainify | truncate 155 }}">
{{ end }}
{{ $canonical := .Permalink }}
{{ with .Params.canonical }}{{ $canonical = . }}{{ end }}
<link rel="canonical" href="{{ $canonical }}">
{{/* Robots meta: default index,follow. Noindex for drafts, private, or thin lists you choose */}}
{{ $robots := "index,follow" }}
{{ if or .Draft .Params.noindex }}{{ $robots = "noindex,follow" }}{{ end }}
{{ if or (eq .Kind "taxonomy") (eq .Kind "term") }}
{{ if not .Params.index }}{{ $robots = "noindex,follow" }}{{ end }}
{{ end }}
<meta name="robots" content="{{ $robots }}">
{{/* Basic Open Graph */}}
<meta property="og:title" content="{{ with .Params.og_title }}{{ . }}{{ else }}{{ .Title }}{{ end }}">
<meta property="og:description" content="{{ with .Params.og_description }}{{ . }}{{ else }}{{ with .Params.meta_description }}{{ . }}{{ end }}{{ end }}">
<meta property="og:url" content="{{ $canonical }}">
<meta property="og:type" content="{{ if eq .Kind "page" }}article{{ else }}website{{ end }}">
{{/* Twitter Card */}}
<meta name="twitter:card" content="summary_large_image">
{{ with .Params.og_image }}
<meta property="og:image" content="{{ . }}">
<meta name="twitter:image" content="{{ . }}">
{{ end }}In each layout, include the partial once.
{{ partial "head.html" . }}- Title: use a page field, then the page title. Add the site name except on the home page. Keep near 60 characters.
- Description: prefer a field. Fall back to a summary. Trim to 155 characters.
- Canonical: default to .Permalink. Allow a per page override.
- Robots: default index,follow. Allow noindex per page and for taxonomy kinds you mark as thin.
- Social: basic Open Graph and Twitter help sharing and search features.
Hugo sitemap: ship a clean sitemap.xml
Hugo has a built-in sitemap template. You can override it. Use a custom template if you need control over what is included.
# config.toml (or .yaml/.json)
baseURL = "https://yourproduct.com/"
[sitemap]
changefreq = "weekly"
priority = 0.5
filename = "sitemap.xml"Override the template to skip noindex pages, drafts and thin taxonomies. Put this at layouts/_default/sitemap.xml. Keep the XML valid. Link absolute URLs only.
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>\n" -}}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{/* Regular pages */}}
{{ range .Site.RegularPages }}
{{ if and (not .Draft) (ne (index .Params "noindex") true) }}
<url>
<loc>{{ .Permalink }}</loc>
{{ with .Lastmod }}<lastmod>{{ .Format "2006-01-02T15:04:05Z07:00" }}</lastmod>{{ end }}
</url>
{{ end }}
{{ end }}
{{/* Taxonomy list pages, only if you mark them indexable */}}
{{ range $taxonomy, $terms := .Site.Taxonomies }}
{{ range $term, $pages := $terms }}
{{ $termPage := index $.Site.GetPage (printf "/%s/%s" $taxonomy $term) }}
{{ if and $termPage (ne (index $termPage.Params "index") false) }}
<url>
<loc>{{ $termPage.Permalink }}</loc>
{{ with $termPage.Lastmod }}<lastmod>{{ .Format "2006-01-02T15:04:05Z07:00" }}</lastmod>{{ end }}
</url>
{{ end }}
{{ end }}
{{ end }}
</urlset>- Submit sitemap.xml in Search Console. Watch the Sitemaps report for errors.
- Do not include paginated list pages unless you need them indexed.
- If you run multiple languages or sections, link to one sitemap index and split per section if needed.
robots.txt: clear rules and safe staging
Hugo can render a robots.txt from a template. Keep it simple. Disallow what must stay out. Point to the sitemap. Lock staging to avoid index leaks.
# layouts/robots.txt
User-agent: *
{{ if eq hugo.Environment "production" }}
Allow: /
Sitemap: {{ .Site.BaseURL }}sitemap.xml
{{ else }}
Disallow: /
# Staging or local. Block all crawling.
{{ end }}- Set HUGO_ENV=production on real deploys. Test the built robots.txt before you ship.
- Do not try to fix index issues with robots.txt. Use noindex in the head for pages that should be public to users but not indexed.
- If you run multiple hosts, make sure BaseURL matches the live domain so the sitemap URL is correct.
Taxonomy pages: avoid thin archives
Hugo creates taxonomy list pages for tags and categories if enabled. Out of the box, these pages can be thin. Thin archives bloat the index and waste crawl.
- Disable taxonomies you do not use.
- Noindex term and taxonomy pages until you curate them.
- If you keep them indexable, add unique copy and internal links.
- Cap pagination. Do not create hundreds of near empty pages.
# config.toml
[taxonomies]
tag = "tags"
category = "categories"
# Remove a line to disable that taxonomy entirelyNoindex in the head partial when the kind is taxonomy or term, unless you opt in with front matter. The head example above shows this pattern.
---
title: "Kotlin tools"
index: true # you allow this term page to be indexed
meta_description: "Tools for Kotlin devs we recommend."
---
Content above the list adds context. Use the term template to render it.{{/* layouts/_default/terms.html or taxonomy.html */}}
<h1>{{ .Title }}</h1>
{{ with .Content }}<div class="term-intro">{{ . }}</div>{{ end }}
<ul>
{{ range .Pages }}
<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>
{{ end }}
</ul>Canonicals: one URL per page
Set a canonical for every page. Hugo does not add one for you. Use .Permalink by default. Allow an override for true duplicates you must keep live.
# In front matter when you need to point elsewhere
---
canonical: "https://yourproduct.com/guides/getting-started/"
---- Do not point canonicals across languages or products unless the content is the same.
- Paginated lists should self canonical. Do not point page 2 to page 1 unless you remove page 2 from the index.
- Avoid query string variants at the server. Hugo builds static paths, so keep links consistent in templates.
Images: alt text, dimensions and fast delivery
Hugo’s image pipeline helps, but templates must call it. Ship correct dimensions to prevent layout shift. Generate responsive formats. Keep the LCP under 2.5 seconds and CLS under 0.1.
{{/* Example for a post hero image in a layout */}}
{{ $img := .Resources.GetMatch .Params.hero }}
{{ with $img }}
{{ $w1200 := .Fit "1200x630" }}
{{ $w800 := .Fit "800x420" }}
{{ $w400 := .Fit "400x210" }}
<link rel="preload" as="image" href="{{ $w1200.RelPermalink }}" imagesrcset="{{ $w400.RelPermalink }} 400w, {{ $w800.RelPermalink }} 800w, {{ $w1200.RelPermalink }} 1200w" imagesizes="(min-width: 768px) 800px, 100vw">
<img
src="{{ $w800.RelPermalink }}"
srcset="{{ $w400.RelPermalink }} 400w, {{ $w800.RelPermalink }} 800w, {{ $w1200.RelPermalink }} 1200w"
sizes="(min-width: 768px) 800px, 100vw"
width="{{ $w800.Width }}" height="{{ $w800.Height }}"
alt="{{ with .Page.Params.hero_alt }}{{ . }}{{ else }}{{ $.Title }} hero image{{ end }}"
loading="lazy" decoding="async">
{{ end }}- Always set width and height attributes. That prevents layout shift.
- Set alt text in front matter or infer from the caption. Do not leave it empty unless the image is decorative.
- Preload the LCP image on templates where the hero is the LCP.
- Compress originals. Hugo can resize, but the input size still affects build and transfer.
- Audit INP too. Keep it under 200 ms. Heavy client scripts slow interaction, even on static HTML.
A Hugo SEO checklist to ship
Head partial included on all layouts
Title, description, canonical, robots meta, Open Graph and Twitter. No duplicates. Home page uses a custom title.
Index rules are explicit
Front matter supports noindex. Taxonomies default to noindex until you opt in. Drafts never index. Staging blocks crawling.
Sitemap.xml includes only indexable URLs
Custom sitemap template filters drafts and noindex. It excludes thin archives and unnecessary pagination. Absolute URLs only.
robots.txt matches the environment
Production allows crawling and links the sitemap. Staging blocks all. BaseURL is correct. You test the live file after deploy.
Canonicals are correct
Default to .Permalink. Overrides exist for true duplicates only. No cross-site canonicals unless content is the same.
Images are optimised
Hero has preload, srcset, width and height. Alt text is set. Binary sizes are compressed. LCP is under 2.5 s, CLS under 0.1.
Internal links are clear
No orphan pages. Primary pages are within two clicks. Navigation and footer link to money pages and key guides.
Submit and monitor in Search Console
Verify the property. Submit sitemap.xml. Check the Sitemaps and Page indexing reports. Fix errors before adding more pages.
- Keep URLs stable. Avoid renames. If you must change, ship 301 redirects at the host.
- Avoid index bloat from autogenerated pages and feeds you do not need.
- Do not ship heavy client scripts. Static HTML is fast. Protect INP and LCP by default.
Questions
No. Hugo does not have a native plugin layer for SEO. You write layouts and partials for metadata, sitemap and robots. Community modules exist, but you can ship the small set you need with a few templates you control.
Use a custom sitemap template and filter on a front matter flag, for example noindex true. Hugo’s built-in template is generic, so override it and test the output. Keep the file valid XML and link absolute URLs.
Usually no. They are thin until you add copy and curation. Default them to noindex in the head partial. When a term page has unique content and demand, let it index and link it from templates.
Create layouts/robots.txt as a template and use hugo.Environment to vary rules. Allow on production and disallow on staging. Point to your sitemap with the BaseURL. Rebuild and check the live file after deploy.
Use self canonicals. Each page of a series should point to itself, not to page 1. If you do not want page 2 indexed, noindex it and keep it out of the sitemap.
Keep pages light. Set width and height on images to avoid layout shift. Preload the LCP image. Avoid heavy scripts that hurt INP. Aim for LCP under 2.5 seconds, CLS under 0.1 and INP under 200 ms.
Sources
Check my site, free
Want a second pair of eyes on your Hugo setup? Run a free check from a URL and see three findings on your site, the searches around it and the rivals in about thirty seconds.
- Free check, no card
- Read-only, your own accounts
- Readable by your agent
Read next
- GuideTechnical SEO checklist for a small site
- GuideOn-page SEO checklist: the twelve things on the page
- GuideCanonical tags: what they do and the mistakes that cost rankings
- GuideThe Sitemaps report: what Success, Has errors and Couldn’t fetch mean
- GlossaryXML sitemap
- Glossaryrobots.txt
- GuideFramer SEO: the settings, the staging trap, and what to check
- GuideNuxt SEO: useSeoMeta, the SEO modules, and the client-rendering trap
Social previews and basics you forget
Social images and structured data improve how your pages appear. Add a default image and site name. Set favicons and a web app manifest once. Then stop touching it.