In this post, I am going to walk you through creating a post series in a Hugo blog.

This process involves defining a “series” as a type of content organization (taxonomy in Hugo), and then editing the site’s templates to display the series information.

When to use Tags vs Series

In Hugo, you can use tags for broad, non-hierarchical keyword associations, and a custom series taxonomy to link a specific sequence of posts. While both are forms of taxonomies for grouping content, they serve different user experience and organizational purposes.

Use a series taxonomy for content that is meant to be consumed in a specific order. For example:

  • multi-part content: If you have a tutorial or an article broken into multiple, sequential posts, a series is the ideal way to link them together.
  • next/previous navigation: A series makes it easy to build “next post in the series” and “previous post in the series” links, guiding the user through the content in the intended order.

Use tags for providing more granular, flexible keywords that describe a post’s content. For example:

  • Related topics: Tags group posts that share common themes but don’t need to be read sequentially. For example, a post in a “travel” category might have tags like Tokyo, packing, and arts.
  • Tag cloud: Tags are perfect for displaying a “tag cloud” or a list of relevant topics to help users discover content.
  • A post can belong to multiple, different groups. Unlike a series, which is typically a single assignment, a post can have many different tags.
  • The order does not matter. Tags are for free-form association, not for sequential navigation.

To summarize, here are their differences:

FeatureTagsSeries
PurposeKeywords for free-form, multi-dimensional groupingOrdered grouping for sequential, multi-part content
RelationMany-to-many. A post can have multiple tags, and a tag can apply to many posts.Typically one-to-many. A post belongs to one series, which contains multiple posts.
OrderNo inherent order.A defined order is implied and can be controlled withweight.
Use CaseDiscovering related topics, tag clouds, broad thematic categorization.Tutorial series, multi-part documentation, sequential narratives.
ConfigurationA default taxonomy in Hugo; minimal setup required.A custom taxonomy you must explicitly define in your config file.

Step 1: Configure the ‘Series’ taxonomy

First, we need to tell Hugo about our new series taxonomy.

  1. Open the blog’s main configuration file in the project root folder. It should be either hugo.toml or config.toml.
  2. Add series to the [taxonomies] section. If you don’t have this section, create it.

If you are using tag or category now, make sure to keep them in the taxonomies section.

Here’s how it should look in hugo.toml:

[taxonomies]
  tag = "tags"
  category = "categories"
  series = "series"  

Step 2: Add posts to a series

Now you can assign any blog post to a series in the front matter by adding a series parameter to its front matter, specifying the name of the series as a string within an array. For example, to add a post to a series called “travel-2025”, you would add:

---
title: "Travel to Osaka World Expo 2025"
date: 2025-10-02T17:28:03-07:00
draft: false
series: ["travel-2025"]  
---

The World Expo ......

Note: A post can technically belong to multiple series, but let’s setup for posts that belong to just one initially.

Step 3: Create the series pages

When you click a series link like yoursite.com/series/travel-2025/, Hugo looks for a template to render all the posts within the travel-2025 series. It follows a specific lookup order. It first looks for layouts/series/list.html. If it doesn’t find that, it falls back to layouts/_default/list.html.

Similarly, Hugo also needs to render a dedicated page listing all series (the page at yoursite.com/series/) requires a different terms template. The lookup order for this is layouts/series/terms.html and then layouts/_default/terms.html.

Many themes have both files by default, and Hugo will use them for any taxonomy that doesn’t have its own specific template. In our case, we will be fine using defaults. However, creating specific files like layouts/series/*.html gives us more customization and control, allowing us to style and present our series pages differently from our tag pages.

For example, the default terms.html in my theme is optimized to display short tags and their associated post counts, looking odd on my series page. With layouts/series/terms.html, I can set the title specifically to All Series and display the series in a list.

1. Main Series page (Lists all series)

This page will be available at yoursite.com/series/. It will show a list of all available series, like “Travel-2025”, “Setup Hugo for blogging”, etc.

  1. Create a new file at layouts/series/terms.html.
  2. Add the following code:
{{/* layouts/series/terms.html */}}

{{ define "main" }}
  <div class="main-content">
    <article>
      <header>
        <h1>All Series</h1>
      </header>
      <ul class="terms-list">
        {{ range .Data.Terms.ByCount }}
          <li>
            <a href="{{ .Page.RelPermalink }}">{{ .Page.Title }}</a> ({{ .Count }} {{ if eq .Count 1 }}post{{ else }}posts{{ end }})
          </li>
        {{ end }}
      </ul>
    </article>
  </div>
{{ end }}

This template ranges over all defined series terms (.Data.Terms), sorts them by the number of posts they contain, and links to each individual series page.

2. Individual Series page (Lists posts in one series)

This page will be available at a URL like yoursite.com/series/travel-2025/.

  1. Create a new file at layouts/series/list.html.
  2. Add the following code:
{{/* layouts/series/list.html */}}

{{ define "main" }}
  <div class="main-content">
    <article>
      <header>
        <h1>Posts in the "{{ .Title }}" Series</h1>
      </header>
      <ol>
        {{ range (where .Pages "Params.type" "post" ).GroupByDate "2006" }}

          <h2 class="posts-year">{{ .Key }}</h2>

          {{- range .Pages -}}
              {{- partial "list-posts.html" . -}}
          {{ end }}
        {{ end }}
      </ol>
    </article>
  </div>
{{ end }}

This template displays the title of the series and then lists every post (.Pages) belonging to it, grouped by the year it was published.

Step 4: Display Series info on our posts

This step involves editing both post and single post templates. It will be used in a post to show the series notice at the top, and a list of other posts at the bottom respectively.

The reason I edit both templates is because in my theme, it is hard to position granularly for the series notice to show up right after the title and tags, and before the content body. Inserting the series code in post.html is much more precise. The list of other posts block can be put in single.html easily.

1. Add “Part of a series” (top of post)

We will link to the series main page on the top of the blog post.

First let’s edit /layouts/partials/post.html. If this file doesn’t exist, copy it from your theme’s folder (themes/your-theme-name/layouts/partials/post.html) into your site’s root layouts/partials/ directory to override it.

In my blog using Gokarna theme, I place this code right after the <div class="post-header-section"> block and before the div class="post-content"> block. It will thus show up right after the title and metadata.

{{/* layouts/_default/single.html */}}

{{ with .Params.series }}
  {{ $seriesName := index . 0 }}
  {{ $seriesPage := $.Site.GetPage (printf "/series/%s" ($seriesName | urlize)) }}
  <div class="series-notice" style="border: 1px solid #ddd; padding: 15px; margin-bottom: 20px; border-radius: 5px;">
    This post is part of the <a href="{{ $seriesPage.RelPermalink }}"><strong>{{ $seriesPage.Title }}</strong></a> series.
  </div>
{{ end }}
  • with .Params.series: This code only runs if the post has a series defined in its front matter.
  • $seriesName | urlize: This converts the series name (e.g., “Travel 2025”) into a URL-friendly format (e.g., “travel-2025”).
  • $.Site.GetPage: This fetches the actual series page so we can get its title and link.

2. Add Series list (bottom of post)

We will then add a list of other posts in this series, so the reader can explore further.

If layouts/_default/single.html doesn’t exist, copy it from your theme’s folder (themes/your-theme-name/layouts/_default/single.html) into your site’s root layouts/_default/ directory to override it. Place this code at the bottom, right before the last {{- end }}.

{{/* layouts/_default/single.html */}}

{{ with .Params.series }}
  {{ $seriesName := index . 0 }}
  {{ $seriesPage := $.Site.GetPage (printf "/series/%s" ($seriesName | urlize)) }}
  
  {{ if gt (len $seriesPage.Pages) 1 }}
    <div class="series-navigation" style="border-top: 1px solid #eee; padding-top: 20px; margin-top: 30px;">
      <h3>Other posts in this series:</h3>
      <ol>
        {{ range $seriesPage.Pages.ByDate }}
          <li>
            {{ if eq .Permalink $.Permalink }}
              <strong>{{ .Title }}</strong>
            {{ else }}
              <a href="{{ .RelPermalink }}">{{ .Title }}</a>
            {{ end }}
          </li>
        {{ end }}
      </ol>
    </div>
  {{ end }}
{{ end }}
  • range $seriesPage.Pages.ByDate: This loops through all pages in the current series, sorted by date.
  • if eq .Permalink $.Permalink: This is the key part. It checks if the page in the loop is the current page you are viewing.
    • If it is, it prints the title in bold without a link.
    • If it’s not, it creates a hyperlink to that post.