Updated 11 min read

Hugo Blog SEO Checklist for Static Sites

A practical Hugo blog SEO checklist: baseURL, permalinks, canonical tags, the embedded Open Graph and schema partials, sitemap and robots.txt settings, drafts and future dates, Git-based lastmod, aliases and a post-deploy curl check.

Blog on Hugo? See how Blogger Dog publishes into Hugo.

Why a fast Hugo site can still be invisible

Hugo gives you a strong SEO starting point. Every page is plain static HTML, builds take seconds, there is no client-side router, and a sitemap and RSS feed are generated without a single plugin. On paper, a Hugo blog should rank as well as its content deserves.

In practice, Hugo blogs lose search traffic in quieter ways. A baseURL left at a staging host leaks into every absolute link. Themes differ wildly in what they put in the <head>: some print a canonical tag and Open Graph data, many print neither. Posts dated a few hours in the future silently fail to build. Tag and category pages flood the sitemap with thin pages. And a renamed post keeps its old URL in search results because nobody added an alias.

This checklist is the setup we would want on any Hugo blog that is meant to bring in search traffic. Settings are checked against the current Hugo documentation (v0.169 at the time of writing), and the examples use hugo.toml, the file name current Hugo projects start with. If you run Astro or Next.js instead, the Astro blog SEO checklist and the Next.js blog SEO checklist cover the same ground for those stacks, and the Docusaurus blog SEO checklist does the same for docs sites with a blog, and the Jekyll blog SEO checklist covers Jekyll on GitHub Pages, and the Gatsby blog SEO checklist covers Gatsby 5. If the blog is hosted on GitLab Pages, the GitLab Pages blog SEO checklist covers the hosting side: the Pages job, custom domains and redirects.

1. Set baseURL to the exact production URL

Everything absolute in a Hugo site is built from baseURL: .Permalink, the sitemap, the RSS feed, Open Graph URLs and absURL. The Hugo settings reference defines it as the absolute URL of the published site, including the protocol, host, path and a trailing slash. Its default is https://example.org/, so a project that never sets it ships a sitemap full of example.org links.


# hugo.toml

baseURL = "https://example.com/"

languageCode = "en-us"

title = "Example Engineering Blog"

enableRobotsTXT = true

enableGitInfo = true



[params]

  description = "Notes on building and running Example."

  images = ["images/og-default.png"]

Use the host you actually serve, with or without www, and keep the trailing slash. Preview deploys on Netlify, Vercel or Cloudflare Pages should override it at build time (hugo --baseURL "$DEPLOY_PRIME_URL" or your host's equivalent) rather than by editing the file, so production never inherits a preview host.

Moving a GitHub Pages site from github.io to your own domain is the classic way to end up with a stale baseURL. The GitHub Pages custom domain guide covers that move step by step: DNS records, HTTPS, and the redirects GitHub does and does not make.

Leave canonifyURLs and relativeURLs at their default of false unless you have a specific reason. The settings reference warns you to read the details before enabling either, and neither is needed for a normal blog.

2. Decide your permalink pattern once

By default a post at content/posts/my-post.md is published at /posts/my-post/. That is fine, but decide before you have traffic, because every change later means redirects. The URL management docs list the tokens you can use, including :year, :month, :section, :slug and :contentbasename. Note that :filename and :slugorfilename were deprecated in v0.144.0 in favour of :contentbasename and :slugorcontentbasename.


[permalinks]

  [permalinks.page]

    posts = "/blog/:slug/"

With :slug, the URL comes from the slug front matter field, falling back to the title. That keeps URLs stable when you rename the file, which is exactly what you want for SEO. Dates in URLs (/2026/10/my-post/) make evergreen posts look stale and make updates awkward, so most blogs are better off without them.

Hugo's default output is "pretty" URLs with a trailing slash. Keep it, use the same style in every internal link, and check in step 10 that your host does not also serve the slashless variant as a second 200.

3. Print a canonical tag in the base template

Hugo does not add a canonical tag on its own; whether you have one depends entirely on your theme. Google's guide to consolidating duplicate URLs recommends absolute canonical URLs and a self-referencing canonical on the preferred page. Add it to your head partial:


{{/* layouts/_partials/head.html */}}

<title>{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} | {{ site.Title }}{{ end }}</title>

<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ with .Summary }}{{ . | plainify | truncate 160 }}{{ else }}{{ site.Params.description }}{{ end }}{{ end }}">

<link rel="canonical" href="{{ .Permalink }}">

.Permalink is built from baseURL and your permalink pattern, so it is absolute and matches the URL in the sitemap. If you syndicate posts elsewhere and want the original to win, point the syndicated copy's canonical back at your Hugo URL, not the other way round.

Check the title too. Many themes append the site name, and some append it twice. View the source of one post and confirm there is one <title>, one description and one canonical.

4. Use the embedded Open Graph, schema and X card partials

Hugo ships embedded templates for social and structured data, documented on the embedded templates page. Current Hugo includes them as partials:


{{ partial "opengraph.html" . }}

{{ partial "schema.html" . }}

{{ partial "twitter_cards.html" . }}

They read data you probably already have. The image comes from the images front matter field; if it is missing, Hugo looks for a page resource whose name contains feature, then cover or thumbnail, then falls back to the first entry in params.images. The description falls back from the page description to the summary to params.description. The X card template emits summary_large_image when it finds an image and a plain summary otherwise.

Two practical rules follow. First, set params.images to a real PNG or JPEG of around 1200 by 630, so every page has a share image even when a post has none. Second, give every post a description: the automatic summary is often the first paragraph cut mid-sentence, which makes a weak search snippet.

If you override one of these templates, copy it into layouts/_partials/ with the same name, as the docs describe, so the partial call picks up your version.

5. Add BlogPosting JSON-LD for posts

The embedded schema.html partial emits schema.org microdata meta tags (name, description, dates, word count, images). Google reads JSON-LD most reliably, and its article structured data guide lists headline, image, datePublished, dateModified and author as the recommended properties. A small partial for single posts covers them:


{{/* layouts/_partials/blogposting.html */}}

{{ if and .IsPage (eq .Section "posts") }}

{{ $data := dict

  "@context" "https://schema.org"

  "@type" "BlogPosting"

  "headline" .Title

  "description" .Description

  "datePublished" (.PublishDate.Format "2006-01-02T15:04:05Z07:00")

  "dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00")

  "mainEntityOfPage" .Permalink

  "author" (dict "@type" "Organization" "name" site.Title)

}}

<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>

{{ end }}

Building the object with dict and jsonify means Hugo handles the escaping, instead of you concatenating quoted strings in a template. Run one post through Google's Rich Results Test after deploying.

6. Get drafts, future dates and expiry right

Hugo will not publish three kinds of content by default. The settings reference lists buildDrafts, buildFuture and buildExpired, all defaulting to false. That means:

  • a post with draft: true is not built;
  • a post whose date or publishDate is in the future is not built;
  • a post with an expiryDate in the past is removed.

The future-date rule catches people out. If you write a post in the evening in one time zone and your CI runs in UTC, a date without an offset can land a few hours in the future, and the post simply does not appear. Use full timestamps with an offset (2026-10-30T09:00:00-05:00) or a plain date, and never run production builds with --buildFuture or --buildDrafts. For scheduled posts, the future-date rule is actually useful: commit early with a future publishDate and rebuild on a cron, and the post goes live on the next build after that time.

7. Let Git set lastmod

Search engines and readers both use the modified date, and a sitemap lastmod that never changes, or changes on every build, is useless. Hugo can take it from Git. The front matter configuration docs show the default order for lastmod:


[frontmatter]

  lastmod = [":git", "lastmod", "modified", "date", "publishdate", "pubdate", "published"]

:git is the Git author date of the file's last commit, and it only works when enableGitInfo = true (already set in step 1). With that, editing a post and committing it updates .Lastmod, which flows into the sitemap and the dateModified in step 5. Two caveats: CI must fetch the full history (a shallow clone with depth 1 gives every file the same date), and a sweeping commit such as a front matter migration bumps every post at once. If that bothers you, put lastmod first and set it by hand on real updates.

8. Trim the sitemap and robots.txt

Hugo builds sitemap.xml on every build. The sitemap configuration docs show the defaults: changeFreq empty and priority at -1, both of which Hugo omits from the file. Leave them that way; Google ignores both fields.

What deserves attention is what goes in. By default the sitemap lists every page kind, including tag and category pages, which are usually thin lists of links. You have three options:

  • exclude one page with sitemap: {disable: true} in its front matter;
  • remove whole page kinds you do not use with disableKinds = ["taxonomy", "term"];
  • override layouts/sitemap.xml if you need a custom filter.

For robots.txt, enableRobotsTXT = true makes Hugo generate one. The robots.txt template docs note that the embedded version contains only User-agent: *, which allows everything but does not point to your sitemap. Add layouts/robots.txt:


User-agent: *

Allow: /



Sitemap: {{ "sitemap.xml" | absURL }}

9. Keep old URLs alive with aliases

When you rename a post or change the permalink pattern, the old URL is what search engines and other sites link to. Hugo's answer is aliases in front matter:


aliases:

  - /posts/old-post-name/

The URL docs explain that Hugo writes an HTML file at each alias path with a canonical link and an instant <meta http-equiv="refresh" content="0; url=...">. Google's redirects guide says it interprets instant meta refresh redirects as permanent. That is good enough when you cannot configure the server. If your host supports a redirects file (Netlify and Cloudflare Pages read _redirects), a real 301 is better, and the docs show how to generate one from each page's .Aliases.

While you are in the templates, make sure every post is reachable through plain links: paginated list pages (/blog/page/2/) rather than a JavaScript "load more", and two or three in-body links to related posts. Hugo's RSS feed is on by default for home, section and taxonomy pages; add the autodiscovery <link rel="alternate"> from the RSS template docs to your head so readers and tools find it. If you want AI assistants to find a clean index of your posts too, our guide to llms.txt for developer blogs covers that file.

10. Check the live page, not the public/ folder

The build output only tells you what Hugo wrote. The host decides what is served. After each deploy, check one real post:


URL="https://example.com/blog/my-post/"



curl -s -o /dev/null -w "%{http_code}\n" "$URL"

curl -s "$URL" | grep -oE '<link rel="?canonical"? href="?[^" >]+' | head -1

curl -s "$URL" | grep -c '<h1'

curl -s https://example.com/sitemap.xml | grep -c "$URL"

curl -sI "${URL%/}" | grep -iE '^(HTTP|location)'

You want a 200, a canonical equal to the URL, exactly one H1 (many Hugo themes print the title as H1, so do not start the post body with # Title), the URL present in the sitemap, and the slashless variant redirecting rather than returning a second 200. This is the same idea behind live URL verification for GitHub blog publishing: a post only counts once its public page proves it.

The checklist in one place

AreaSetting or fileWhat good looks like
Base URLbaseURL in hugo.tomlProduction origin with a trailing slash; previews override it at build time
URLs[permalinks]One pattern, :slug-based, decided early
Canonicalhead partial<link rel="canonical" href="{{ .Permalink }}"> on every page
Social and schemaembedded partialsopengraph.html, twitter_cards.html, params.images set to a PNG
Structured datacustom partialBlogPosting JSON-LD built with dict and jsonify
PublishingbuildDrafts, buildFutureOff in production; dates with an offset
FreshnessenableGitInfo, [frontmatter]Git-based lastmod, full-history clone in CI
SitemapdisableKinds, sitemap.disableOnly pages worth indexing
RobotsenableRobotsTXT, layouts/robots.txtAllows crawling and lists the sitemap
Renamed postsaliases or _redirectsOld URLs redirect to the new one
Verificationcurl after deploy200, matching canonical, one H1, in the sitemap

FAQ

Is Hugo good for SEO?

Yes. Static HTML, fast builds, a built-in sitemap and RSS, and no client-side routing give search engines exactly what they need. The gaps come from configuration and themes: baseURL, a missing canonical, social images, and which pages end up in the sitemap.

Does Hugo add a canonical tag automatically?

No. The canonical tag comes from your theme's head template. If yours has none, add <link rel="canonical" href="{{ .Permalink }}"> to the head partial.

Why is my Hugo post missing after a build?

The usual causes are draft: true, a date or publishDate in the future relative to the build machine's clock, or an expiryDate in the past. Run hugo list drafts and hugo list future to see which posts are being skipped.

Should I put dates in Hugo blog URLs?

Usually not. /blog/:slug/ keeps URLs short and stable, and an updated evergreen post does not look old because of its URL. Whatever you choose, choose it before the site has links, and use aliases if you ever change it.

Where should posts live in a Hugo repo?

In a section of content/, usually content/posts/ or content/blog/, so your theme's list and single templates render them. Our Hugo integration page explains how Blogger Dog detects that section and writes posts into it.

Keep the posts coming

A well-configured Hugo blog with no new posts is still a quiet blog. Blogger Dog researches and writes cited SEO and AEO posts, commits them into your Hugo content section as Markdown with title, date, description, tags and slug front matter (no draft: true, so a normal build publishes them), and only counts a post once its public URL is live. It works the same way for Next.js, Astro, Jekyll, Gatsby and Docusaurus, on GitHub or GitLab.

You get 3 posts free, then plans start at $19/month. See pricing and plans.

Put your blog on autopilot

Blogger Dog researches, writes and commits SEO/AEO posts like this one straight into your GitHub or GitLab repo on a schedule. Your first 3 posts are free, then plans start at $19/month.

More from the archive