Skip to content

Hugo integration

Hugo is a fast static site generator written in Go. Use seoslug at build time to generate Open Graph, Twitter Card, JSON-LD, and robots metadata for every page, then inject it via a template override.

seoslug ships a HugoBuilder class at seoslug.contrib.hugo that handles content scanning, frontmatter parsing (TOML and YAML), SEO payload building, and frontmatter injection in a single call:

from seoslug.contrib.hugo import HugoBuilder

builder = HugoBuilder(
    content_dir="content",
    site_url="https://yoursite.com",
    site_name="Your Site",
)
builder.build()

The class accepts all SEOConfig fields as keyword arguments and exposes a dry_run mode for inspection.

See the HugoBuilder API reference for all options.

Manual pre-build script (alternative)

If you prefer full control over the generation logic, write a custom script that imports seoslug directly.

1. Write the SEO generation script

Create scripts/generate_seo.py. This script reads each content file under content/, builds an SEO payload with seoslug, and injects the rendered HTML into the frontmatter:

"""Pre-build: generates SEO frontmatter for Hugo content via seoslug."""

from __future__ import annotations

import re
import sys
from pathlib import Path

import yaml
from seoslug import (
    SEOConfig,
    URLPolicy,
    SEOEntity,
    OGImage,
    Robots,
    build_seo_payload,
)

PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONTENT_DIR = PROJECT_ROOT / "content"
SITE_URL = "https://yoursite.com"
SITE_NAME = "Your Site"

SEO_CONFIG = SEOConfig(
    canonical_host="yoursite.com",
    public_base_url=SITE_URL,
    url_policy=URLPolicy(
        enforce_https=True,
        lowercase_paths=True,
        trailing_slash="never",
    ),
    site_name=SITE_NAME,
    default_og_image=OGImage(
        url=f"{SITE_URL}/images/default-og.png",
        width=1200,
        height=630,
    ),
    publisher_name="Your Name",
    title_template="{title} | Your Site",
    default_robots=Robots(index=True, follow=True),
    locale="en_US",
    twitter_site="@yourhandle",
)

FM_RE = re.compile(
    r"^-{3}[ \r\t]*?\n(.*?\r?\n)(?:\.{3}|-{3})[ \r\t]*\n",
    re.UNICODE | re.DOTALL,
)


def route_path_from_file(filepath: Path) -> str:
    rel = filepath.relative_to(CONTENT_DIR)
    parts = list(rel.parts)
    if parts[-1] == "_index.md":
        parts.pop()
        if not parts:
            return "/"
        return "/" + "/".join(parts) + "/"
    stem = Path(*parts).with_suffix("")
    return "/" + str(stem) + "/"


def first_heading(text: str) -> str | None:
    match = re.search(r"^#\s+(.+)", text, re.MULTILINE)
    if not match:
        return None
    return re.sub(r"[`*_]", "", match.group(1).strip())


def extract_excerpt(body: str, max_chars: int = 160) -> str:
    body = re.sub(r"^#\s+.*\n?", "", body, count=1).strip()
    para = re.split(r"\n\s*\n", body, maxsplit=1)[0].strip()
    clean = re.sub(r"[`*_\[\]()>|#{}]", "", para)
    clean = re.sub(r"\s+", " ", clean).strip()
    if not clean:
        return ""
    if len(clean) <= max_chars:
        return clean
    break_at = clean.rfind(" ", 0, max_chars)
    return clean[:break_at] + "..." if break_at > 0 else clean[:max_chars] + "..."


def read_meta_and_body(filepath: Path) -> tuple[dict, str]:
    text = filepath.read_text(encoding="utf-8")
    meta: dict = {}
    body = text
    if text.startswith("---"):
        match = FM_RE.match(text)
        if match:
            try:
                parsed = yaml.safe_load(match.group(1))
                if isinstance(parsed, dict):
                    meta = parsed
            except Exception:
                pass
            body = text[match.end():].lstrip("\n")
    return meta, body


def main() -> int:
    changed = 0
    for md_file in sorted(CONTENT_DIR.rglob("*.md")):
        rel_path = md_file.relative_to(PROJECT_ROOT)
        meta, body = read_meta_and_body(md_file)
        route = route_path_from_file(md_file)

        title = meta.get("title") or first_heading(body) or SITE_NAME
        description = meta.get("description") or extract_excerpt(body)
        entity_type = "post" if route != "/" and meta.get("entity_type") == "post" else "page"

        entity = SEOEntity(
            entity_type=entity_type,
            title=title,
            excerpt=description or None,
        )
        payload = build_seo_payload(entity, route, SEO_CONFIG)
        if payload is None:
            continue

        new_html = payload.render_html()
        old_html = meta.get("seo_html", "")
        if new_html == old_html:
            continue

        meta["seo_html"] = new_html
        fm_dump = yaml.dump(meta, default_flow_style=False, allow_unicode=True, sort_keys=False)
        md_file.write_text(f"---\n{fm_dump}---\n\n{body}", encoding="utf-8")
        print(f"  WRITE {rel_path}")
        changed += 1

    print(f"\nDone: {changed} changed")
    return 0


if __name__ == "__main__":
    sys.exit(main())

2. Override the head partial

Create layouts/_partials/head.html in your project. This mirrors the default Hugo theme's head but checks for seo_html first:

<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">

{{ if .Params.seo_html }}
  {{ .Params.seo_html | safeHTML }}
{{ else }}
  <title>{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} | {{ site.Title }}{{ end }}</title>
  <meta name="description" content="{{ .Description | default site.Params.description }}">
  <link rel="canonical" href="{{ .Permalink }}">
  <meta name="robots" content="index,follow">

  {{ template "_internal/opengraph.html" . }}
  {{ template "_internal/twitter_cards.html" . }}

  {{ if eq .IsHome true }}
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "WebSite",
    "name": "{{ site.Title }}",
    "url": "{{ site.BaseURL }}"
  }
  </script>
  {{ end }}
{{ end }}

When a page has seo_html in its frontmatter, seoslug provides the complete set of tags (title, description, canonical, robots, Open Graph, Twitter Card, JSON-LD). Pages without it fall back to Hugo's built-in SEO partials.

3. Update the CI workflow

Add seoslug and the pre-build script to your deployment pipeline:

- uses: actions/setup-python@v5
  with:
    python-version: '3.x'
- run: pip install "seoslug>=2.0.1" pyyaml
- run: python scripts/generate_seo.py
- uses: peaceiris/actions-hugo@v3
  with:
    hugo-version: 'latest'
- run: hugo --minify
- uses: peaceiris/actions-gh-pages@v4
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    publish_dir: ./public

Theme-specific: PaperMod

If you use the PaperMod theme, you can override layouts/_partials/extend_head.html instead of replacing head.html entirely. This keeps the theme's base structure and only appends seoslug tags:

{{ if .Params.seo_html }}
  {{ .Params.seo_html | safeHTML }}
{{ end }}

Note that PaperMod's default head.html also generates Open Graph and Twitter Card tags inside a {{ if hugo.IsProduction }} guard. Using extend_head.html produces duplicate tags. For a clean integration, override head.html as shown above, or modify the theme's production guard by copying its head.html into layouts/_partials/head.html and adding the seoslug check.