Right, let’s get our hands dirty. You’re using Hugo, which means you’re smart enough to appreciate a static site, but now you need to speak Google’s language. SEO isn’t magic; it’s plumbing. It’s about making sure the crawlers can find all your pages, understand what they’re about, and not get lost in the parts of your site you’d rather they ignore. We do this with three core tools: a sitemap, a robots.txt file, and a heap of meta tags in your <head>. Instead of pasting this into every single layout file, we’re going to be adults about it and create a single, glorious partial template.

I’ll call mine seo.html and place it in layouts/partials/. This file is going to be included in your base baseof.html template, right inside the <head> section. This is the way.

The Core SEO Partial Structure

Here’s the boilerplate. Don’t just copy it; we’re going to walk through every line like it’s a suspect in a detective novel.

<!-- layouts/partials/seo.html -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
  {{- if .IsHome -}}
    {{ site.Title }} | {{ site.Params.tagline }}
  {{- else -}}
    {{ .Title }} | {{ site.Title }}
  {{- end -}}
</title>

{{/* Your favicon, probably in your static directory */}}
<link rel="icon" href="{{ site.Params.favicon | default "/favicon.ico" | absURL }}">

{{/* The big one: Page description. Truncation is key. */}}
{{- $description := .Params.description | default .Summary | default site.Params.description -}}
<meta name="description" content="{{ $description | truncate 155 }}">

{{/* Canonical URL: Critical for avoiding duplicate content penalties. */}}
<link rel="canonical" href="{{ .Permalink }}">

{{/* OpenGraph tags for rich sharing on Facebook, LinkedIn, etc. */}}
<meta property="og:title" content="{{ .Title }} | {{ site.Title }}" />
<meta property="og:description" content="{{ $description | truncate 300 }}" />
<meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="{{ .Params.image | default site.Params.og_image | absURL }}" />

{{/* Twitter Card tags, because Twitter has to be different. */}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ .Title }} | {{ site.Title }}">
<meta name="twitter:description" content="{{ $description | truncate 200 }}">
<meta name="twitter:image" content="{{ .Params.image | default site.Params.og_image | absURL }}">

{{/* Let Hugo's built-in magic generate the RSS feed link */}}
{{ range .AlternativeOutputFormats -}}
    {{ printf `<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
{{ end -}}

{{/* This is where we'll inject the robots meta tag later */}}
{{ partial "robots-meta.html" . }}

Why the Truncation Madness?

Notice I’m ruthlessly truncating the description in several places. Here’s why: while Google says it can use up to ~320 pixels for meta descriptions, in practice, it often truncates them to around 155-160 characters in the SERPs. If you don’t control it, it will grab a snippet of text from your page, potentially cut off a word, and look unprofessional. You’re the author. You decide what that preview text says. The different lengths for OG and Twitter are because each platform has its own arbitrary and frequently changing character limits. It’s absurd, but it’s the game we play.

Handling the Robots Meta Tag

You saw that last line injecting another partial? Good. I don’t like messy logic in my main partial. The robots-meta.html partial handles the robots meta tag, which tells crawlers what to do with this specific page. This is different from robots.txt, which is a site-wide rulebook.

<!-- layouts/partials/robots-meta.html -->
{{/* If a page has explicitly set its own robots parameter in front matter, use that */}}
{{- with .Params.robots -}}
<meta name="robots" content="{{ . }}">
{{- else -}}
  {{/* Otherwise, default behavior. Let's be smart: noindex pagination pages! */}}
  {{- if eq .Kind "term" -}}
    {{- if gt .Paginator.PageNumber 1 -}}
<meta name="robots" content="noindex, follow">
    {{- else -}}
<meta name="robots" content="index, follow">
    {{- end -}}
  {{- else -}}
<meta name="robots" content="index, follow">
  {{- end -}}
{{- end -}}

This is a classic “trench” move. Tag pages (eq .Kind "term") are often paginated. You want Google to index the first page of your “Hugo” tag (e.g., /tags/hugo/), but you absolutely do not want it wasting its crawl budget on page 17 (/tags/hugo/page/17/). This logic achieves that by noindexing any paginated page beyond the first. It tells Google, “Don’t list this in your results, but feel free to follow the links on it to find the actual content.”

Generating the XML Sitemap

Hugo generates a sitemap for you by default. It’s… fine. But you might want to customize it. The good news is you can override the built-in template. Create a file at layouts/_default/sitemap.xml. Here’s a more robust version than the default:

<!-- layouts/_default/sitemap.xml -->
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:xhtml="http://www.w3.org/1999/xhtml">
  {{- range .Data.Pages -}}
    {{- if and (not .Params.private) (ne .Kind "term") -}}
      {{- /* Omit pages with noindex or those you mark as private */ -}}
  <url>
    <loc>{{ .Permalink }}</loc>{{- if not .Lastmod.IsZero }}
    <lastmod>{{ .Lastmod.Format "2006-01-02" }}</lastmod>{{- end }}
    <changefreq>{{ .Params.sitemap.changefreq | default "monthly" }}</changefreq>
    <priority>{{ .Params.sitemap.priority | default 0.8 }}</priority>
  </url>
    {{- end -}}
  {{- end -}}
</urlset>

This version skips any page with private: true in its front matter and also skips all taxonomy term pages (ne .Kind "term"). Why? Because while you might want to index the first page of a tag (as we did with the robots meta tag), you almost certainly don’t need to give every paginated page of a tag its own sitemap entry. It’s clutter. The changefreq and priority can be set per-page in front matter for fine-grained control, which is much more powerful than the default sitemap.

The robots.txt File

This is the simplest part. Create a plain text file in your static/ directory, named robots.txt. It’s just a traffic cop.

# static/robots.txt
User-agent: *
Allow: /

# Let's be good netizens and point them to the sitemap.
# Hugo's default sitemap is always at /sitemap.xml
Sitemap: {{ .Site.BaseURL }}sitemap.xml

Hugo will serve this file directly from your static folder. The key line is Sitemap:. This is how you formally tell all crawlers where to find your beautifully crafted sitemap. It’s not strictly necessary, as they’ll often look for it anyway, but it’s the polite and explicit thing to do.

The real power is combining these pieces. The robots.txt tells crawlers where the sitemap is, the sitemap tells them which pages are important, and the meta tags on each individual page tell them exactly what that page is about and how to handle it. It’s a system, not a single silver bullet. Now go implement it.