In this post, I am going to walk you through organizing Hugo blog posts in the right taxonomy .

Almost all Hugo blog themes come with layout templates to support the default tag taxonomy (e.g., see Gokarna’s tags page ). By default , Hugo supports both tags and categories, and it will look for the list and terms layout template to display both. I notice that many themes will only support tags out of the box, and if you want to use categories, Hugo will use the same tags template to display it, resulting in a mismatched title .

What if we don’t only want a separate categories page, we also want a new type of “series” taxonomy to organize serialized content?

Let me walk you through extending the default tags templates to support all taxonomies. By implementing these universal templates, we can consolidate taxonomy page rendering, simplifying theme maintenance while producing dynamic content.

By the end of this post, you will be able to use tags, series, categories (or any self-defined taxonomy) to organize content. They will all have a consistent look as they use the same HTML layout templates.

How to use taxonomy in Hugo

Hugo is very flexible in taxonomy management, you can create custom lists and terms to display your content. For example, you might have already used tags or categories in your existing blog. Think of taxonomies just as a list of pages with a similar type or container.

While both tags and series 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, non-hierarchical 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 with weight.
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.

Since tag or category are supported by default , they do not need to be declared in an explicit [taxonomies] section. However, once you add this section, you must keep tag and category in it to preserve your existing tags and categories.

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 come with both layouts/_default/list.html and layouts/_default/terms.html, and Hugo will use them for any taxonomy that doesn’t have its own specific template. In our case, we will use the same default templates to handle the rendering for all our taxonomies (series, categories, and tags) and simply change the title based on the context. In this way, we can have a single template for all taxonomies but style and present our series pages differently from our tag pages.

We can achieve this by leveraging Hugo’s Page Variables, specifically .Type, and the taxonomy-specific variables like .Title and .Data.Singular.

A. Main Series page (Lists all series)

The terms.html template will produce the yoursite.com/series/, yoursite.com/tags/ and yoursite.com/categories/ pages, listing all the terms (e.g., all the names of our series, categories, or tags).

  1. Copy layouts/_default/terms.html from your themes folder to your project root folder to safely override it.
  2. Use the following code:
{{ define "main" }}
<div class="container tags-list">
    
    <h1 class="list-title">{{ .Data.Plural | humanize }}</h1>
    
    {{ if eq (len .Data.Terms) 0 }}
        {{ i18n "nothing" }}
    {{ else }}
        <ul class="post-tags">
            {{ range .Data.Terms.ByCount }}
            <li class="post-tag">
                <a href="{{ .Page.RelPermalink }}">
                    <div class="tag-name">{{ .Name }}</div>
                    <div class="tag-posts-count">{{ .Count }}</div>
                </a>
            </li>
            {{ end }}
        </ul>
    {{ end }}
</div>
{{ end }}

Explanation:

  • .Data.Plural holds the plural name of the current taxonomy (e.g., “series”, “categories”, “tags”), used to generate a dynamic title
  • {{ .Data.Plural | humanize }} outputs the name of the taxonomy being listed, capitalized and ready for the title. If the URL is /tags/, it outputs Tags. If the URL is /series/, it outputs Series.
  • This template ranges over all defined taxonomy terms (.Data.Terms), sorts them by the number of posts they contain, and links to each individual series/tags/categories page.

B. Individual Series page (Lists posts in a series)

The list.html template is used to list all the posts associated with a single term (e.g., all posts in the “travel-2025” series or with the “travel” tag). For example, it produces a page at a URL like yoursite.com/series/travel-2025/.

This is where we’ll construct the dynamic title depending on the taxonomy. The key is to use the .Data variable.

  1. Copy layouts/_default/list.htm from your theme to the project root folder.
  2. Use the following code:
<div class="container list-posts">
    <h1 class="list-title">
        {{ if .Data.Singular }}
            {{ $term := .Title }}
           
            {{ if eq .Data.Singular "series" }}
                Posts in "{{ $term }}" Series
            {{ else if eq .Data.Singular "tag" }}
                Posts with "{{ $term }}" Tag
            {{ else if eq .Data.Singular "category" }}
                Posts in "{{ $term }}" Category
            {{ else }}
                {{ .Data.Plural | humanize }} for "{{ $term }}"
            {{ end }}

        {{ else }}
            {{ i18n .Name }}
        {{ end }}
    </h1> 

    {{ with .Params.description }}
        <p class="section-desc">{{ . }}</p>
    {{ end }}

    {{ if .Content }}
        <div class="section-intro">
            {{ .Content }}
        </div>
    {{ end }}

    {{ range (where .Pages "Params.type" "post" ).GroupByDate "January 2006" }}

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

        {{- range .Pages -}}
            {{- partial "list-posts.html" . -}}
        {{ end }}
    {{ end }}

</div>

How the Logic Works:

  1. {{ if .Data.Singular }}: This is the initial check to confirm we are on a specific taxonomy term page (like /tags/hugo/). If true, we proceed with the custom titles.

  2. {{ $term := .Title }}: This assigns the current taxonomy term’s name (e.g., “Travel”, “Hugo”) to a variable for cleaner code.

  3. The .Data variable is populated with term-specific data (.Data.Singular, .Data.Plural) only when you are on a specific taxonomy term page (like /series/travel-2025/ or /tags/travel/). Thus checking for {{ if .Data.Singular }} is a reliable way to confirm the context is a taxonomy term page

    • {{ if eq .Data.Singular "series" }}: If the taxonomy is a series, it outputs: Posts in "XY" Series.
    • {{ else if eq .Data.Singular "tag" }}: If the taxonomy is a tag, it outputs: Posts with "XY" Tag.
    • {{ else if eq .Data.Singular "category" }}: If the taxonomy is a category, it outputs: Posts in "XY" Category.
    • {{ else }}: If none of the above, the default list page logic {{ i18n .Name }} is used. This is typically used to title the page with the section name (e.g., “Posts”).

    If you’re not picky with grammar like me (i.e., the difference between in and with), you can use same sentence for all taxonomy, replacing the first 3 if..else clauses with: html {title="layouts/_default/list.html"} {{ $taxonomyName := .Data.Singular | humanize }} All Posts in the "{{ .Title }}" {{ $taxonomyName }}

  4. It then lists every post (.Pages) belonging to it, grouped by the month-year it was published. For example:

URL.Title.Data.SingularOutput Title
site.com/series/travel-2025Travel 2025seriesPosts in “Travel 2025” series
site.com/tags/travelTraveltagPosts with “Travel” tag

Step 4: Display Series info in posts

Now we want to control how individual blog posts will show the series. We will first show a series notice at the top (“This post is part of “XY” series). At the end of the page, it will list other posts in the same series.

This step involves editing both post and single post templates.

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.

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

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.

{{ 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.

B. Add Series list (post bottom)

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 }}.

{{ 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.