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:
Feature | Tags | Series |
---|---|---|
Purpose | Keywords for free-form, multi-dimensional grouping | Ordered grouping for sequential, multi-part content |
Relation | Many-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. |
Order | No inherent order. | A defined order is implied and can be controlled withweight . |
Use Case | Discovering related topics, tag clouds, broad thematic categorization. | Tutorial series, multi-part documentation, sequential narratives. |
Configuration | A 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.
- Open the blog’s main configuration file in the project root folder. It should be either
hugo.toml
orconfig.toml
. - 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.
- Create a new file at
layouts/series/terms.html
. - 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/
.
- Create a new file at
layouts/series/list.html
. - 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 insingle.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 aseries
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.
Comments