SEO for Hugo websites

This tutorial will show you how to improve the SEO of your Hugo site. Let’s get started!

Ron Erdos • Updated July 16, 2020
Tested with Hugo version 0.74.3

SEO titles & meta descriptions

Before we delve into how to optimise these in Hugo, let’s take a quick look at what these fields are, where they show up, and what they do.

SEO titles and SEO descriptions in Hugo: How to generate them

Before we get into the specifics, I want to be clear that the approach shown below is for “old-school” blog homepages which display, say, the ten most recent posts, and then paginates.

However, if you are using a “marketing-style” blog homepage (which asks for the user’s email address and does not display any blog posts), then you’ll need to tweak the code in this section. That said, you’ll probably still find a lot of it useful.

Onwards!

Broadly, the code I’ll show you in just a bit produces three SEO outcomes.

SEO Outcome 1
For the homepage (i.e. /), the SEO title and description will be populated from the Hugo config file, config.toml.

SEO Outcome 2
If the above homepage spills over into paginated pages (e.g. page 2, page 3 etc), then for these pages, use the same SEO title as for the pure homepage, but append | Page 2 (or whatever number is appropriate) to the SEO <title>.

For example, if your site’s homepage SEO <title> is ABC Widgets, then Page 2’s SEO <title> would be ABC Widgets | Page 2.

This ensures each page has a unique SEO title, which is something Google likes to see. Further, the SEO description is similarly made unique for each of these paginated pages.

SEO Outcome 3
Finally, if the page is a blog post (e.g. /my-blog-post/) or page (e.g. /about/) and it has a custom SEO <title>, use it. If it doesn’t have one, re-use the traditional headline (e.g. the <h1>) for the SEO <title>. In both cases, use the custom SEO description in the meta description field.

Code for playing along

Here’s the relevant Hugo SEO code I use in my <head> section:

{{ $paginator := .Paginate (where .Site.RegularPages ".Params.post" "!=" false) }}

{{ if .IsHome }}
	{{ if eq $paginator.PageNumber 1 }}
		<title>{{ .Title }}</title>
		<meta name="description" content="{{ .Site.Params.description }}" />
	{{ else }}
		<title>{{ .Title }} | Page {{ $paginator.PageNumber }}</title>
		<meta name="description" content="This is page {{ $paginator.PageNumber }} of our content." />
	{{ end }}
{{ else }}
	{{ if .Params.seoTitle }}
		<title>{{ .Params.seoTitle }}</title>
	{{ else }}
		<title>{{ .Title }}</title>
	{{ end }}
	<meta name="description" content="{{ .Description }}" />
{{ end}}

Code walkthrough

Okay, that’s a lot of code to walk through! Bear with me, and let’s walk through it, line by line.

{{ $paginator := .Paginate (where .Site.RegularPages ".Params.post" "!=" false) }} This is where we set up the paginator object. You need to declare this here, even if you’ve declared it elsewhere in your Hugo site. The where statement I’ve used in my paginator object helps allow me to have subfolderless urls (of the form example.com/my-blog-post). For more on this, see my article on subfolderless urls in Hugo.

{{ if .IsHome }} This is an “if statement” which checks to see if the page is the homepage.

{{ if eq $paginator.PageNumber 1 }} This sets us up to produce SEO Outcome 1, as described above. We’re checking if the current page is equal to (eq) the pure version of the homepage, such as example.com/.

<title>{{ .Title }}</title> This populates the SEO <title> of the homepage with the site title from our configuration file, config.toml.

<meta name="description" content="{{ .Site.Params.description }}" /> Similarly, this populates the homepage’s SEO description from config.toml.

{{ else }} Now we move on to Page 2, Page 3 etc of the homepage (SEO Outcome 2, as above).

<title>{{ .Title }} | Page {{ $paginator.PageNumber }}</title> For these pages, we want the same SEO <title> as for the “pure” homepage, just with the page number appended so that it is unique. For example, if your site’s homepage SEO <title> is ABC Widgets, then Page 2’s title would be ABC Widgets | Page 2.

<meta name="description" content="This is page {{ $paginator.PageNumber }} of our content." /> Here we make our SEO description unique for each of these paginated pages. For example, Page 2 would have this SEO description: “This is page 2 of our content”. Of course, you could write a better SEO description than this, but you get the idea.

{{ end }} Here we end the code block we started at {{ if eq $paginator.PageNumber 1 }}

{{ else }} Now we start on SEO Outcome 3 (see above). We’re doing the SEO <title>s and descriptions for blog posts and pages (technically, anything which isn’t the homepage).

{{ if .Params.seoTitle }} If an seoTitle parameter has been set in the YAML front matter of the post …

<title>{{ .Params.seoTitle }}</title> … then use it as the SEO <title>.

{{ else }} However, if one hasn’t been written …

<title>{{ .Title }}</title> … then the fallback is to make the <h1> title serve double-duty as the SEO <title>.

{{ end }} That wraps up SEO <title>s …

<meta name="description" content="{{ .Description }}" /> Either way, with or without a custom SEO <title> in the YAML front matter, use the YAML description field to populate the SEO description.

{{ end}} We’re done here!

Do we really need to make each SEO title and SEO description unique?

Above, I mentioned that we should make the SEO <title> and SEO description of each page unique, to keep Google happy.

I want to cover this a bit more, for the interested. Feel free to scroll down otherwise!

Out of the box, Hugo’s pagination delivers a slightly sub-optimal SEO experience.

If you have paginated list content, such as on your homepage or in your sections, then you will end up with urls like this:

/
/page/2
/page/3

All well and good.

The slight SEO issue is that each of those pages will have the same document <title> and meta description, which is something Google cautions against:

Avoid repeated or boilerplate titles. It’s important to have distinct, descriptive titles for each page on your site.

However, one could argue this doesn’t apply to paginated pages.

In March of 2018, John Mueller, a Webmaster Trends Analyst at Google, was asked:

John, do you agree that people can safely ignore the duplicate meta description warning in Google Search Console for paginated archive URLs?

In other words, is this Hugo pagination issue a problem for SEO?

John replied:

Yep, that’s fine [to ignore the relevant Google Search Console warning].

It’s useful to get feedback on duplicate titles & descriptions if you accidentally use them on totally separate pages, but for paginated series, it’s kinda normal & expected to use the same.

Okay, so it may not be a problem for Google; however I believe the best way to control how Google treats your on-page content is to exercise careful governance over what you publish. And my code above does exactly that.

Canonicals

I like to include what’s called a “self-referential canonical” on each page of my sites.

Let me explain this whole kit and caboodle below.

Canonicals: Code for sites without pagination

If you don’t have pagination on your Hugo site, then the code for canonicals is pretty straightforward.

Here’s the Hugo code for inserting a self-referential canonical into the layout file that generates the <head> section:

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

Code walkthrough

The {{ .Permalink }} variable in the line of code above ensures that you’ll get a self-referential canonical on every page of your Hugo site, be it homepage, blog post, page, index and so on.

Canonicals: Code for sites that have paginated homepages

However, if you have a paginated homepage, then the code below will work better:

{{ $paginator := .Paginate (where .Site.RegularPages ".Params.post" "!=" false) }}
<!-- Remove the line above if you already have it in the file, you don't need it twice in the same file (you do need it once in each file that refers to the paginator object though). -->
{{ if .IsHome }}
  {{ if eq $paginator.PageNumber 1 }}
    <link rel="canonical" href="{{ .Permalink }}" />
  {{ else }}
    <link rel="canonical" href="{{ .Permalink }}page/{{ $paginator.PageNumber }}/" />
  {{ end }}
{{ else }}
  <link rel="canonical" href="{{ .Permalink }}" />
{{ end }}

Code walkthrough

The code above generates canonical links.

First things first; we’ll need the line creating the $paginator object somewhere in this file (which for me is /layouts/partials/header.html). It’s not sufficient to create the $paginator object in another file (such as in your homepage template) in your Hugo project; it needs to be in this file too. However, if you’ve already created it in this file (as I did when I set up my SEO <title>s above), there’s no need to include it twice in the one file.

OK, moving on to the if/else statement.

First, we check if the page is the homepage, with {{ if .IsHome }}.

Next, we check if the page is the pure homepage, i.e. not a paginated “Homepage page 2” or similar. We run this check with {{ if eq $paginator.PageNumber 1 }}. Remember that eq is used instead of =, and that the word order is different to most programming languages. Anyway, if it is in fact the pure homepage, then we generate a simple, self-referential canonical link with <link rel="canonical" href="{{ .Permalink }}" />.

However, if the page is the homepage but page 2 or onwards, then we set up our canonical links to include the page number so that they are truly self-referential. We do this with an else statement and then this line: <link rel="canonical" href="{{ .Permalink }}page/{{ $paginator.PageNumber }}/" />

For example, a Hugo “Homepage page 2” would generally have a url of the form https://example.com/page/2. The canonical code discussed in this paragraph gives that page a truly self-referential canonical that matches that url exactly.

Finally, if the page is not the homepage at all—such as a blog post or a contact page—then we can use a simple canonical link generator to create accurate self-referential canonicals. We use our trusty {{ .Permalink }} to create the canonical link: <link rel="canonical" href="{{ .Permalink }}" />.

OK, so there’s a fair bit going on here, but it’s actually simpler than it looks at first glance—I promise.

The “noindex” and “noarchive” tags

Now it’s time to discuss how we want Google to treat our webpages when it comes to indexing and caching. Let me explain.

Code for playing along

If I want to noindex a webpage in a Hugo site (such as the “thank you” page), I simply add a custom parameter to the front matter of that page, like this: noindex: true. I choose to call it noindex, but you can change this if you like.

Then in the layout file that generates the <head> section, I include this code:

{{ if .Params.noindex }}
	<meta name="robots" content="noindex" />
{{ else }}
	<meta name="robots" content="noarchive">
{{ end }}

Code walkthrough

Let’s go through this code, line by line.

{{ if .Params.noindex }} This line checks to see if the noindex parameter is set to true in a given page. Note the terse syntax; you don’t have to explicitly say “equals true”.

<meta name="robots" content="noindex" /> If the noindex parameter is set to true, then generate a noindex tag, thus blocking Google from indexing the page.

{{ else }} On the other hand, if the noindex parameter is set to false, or simply doesn’t exist (I do the latter), then …

<meta name="robots" content="noarchive"> … show the noarchive tag instead …

{{ end }} … and end our “if statement”.

A minor SEO issue: the 404 page

I cover this here: Minor SEO issue with 404 page in Hugo.

"Thanks so much for your work ... I'm migrating my WordPress blog to Hugo and it's been really helpful" — Francisco S., engineer and blogger

The planets in our solar system