16.7 Grouping Pages: ByDate, BySection, ByParam
Right, so you’ve got a list of pages. A big, flat, boring list. Hugo’s default range is great for a simple blog roll, but let’s be honest, most sites need a bit more structure. You don’t just dump every single product or article on one page and call it a day. You want to group them, organize them, make sense of the chaos. That’s where these grouping functions come in. They are the unsung heroes of making your Hugo site look like it was designed by a thoughtful human and not a spreadsheet macro.
Think of ByDate, BySection, and ByParam as your three main tools for wrangling content. They all take your current page’s .Pages collection and return a map. The key is the thing you’re grouping by, and the value is a slice of all the pages that share that key. It’s GROUP BY for your templates, and it’s incredibly powerful.
Grouping by Publication Date
ByDate is your go-to for anything chronological. Its most obvious use is for archive pages, but don’t sleep on its versatility. The key thing to understand—and this is where people often get tripped up—is that the key of the map it returns is a time.Time object, not a string.
You provide a “field” for the date, which defaults to the publish date ("date"). You can also use "lastmod" or any other front matter field of type date.
{{/* Group pages by their year of publication */}}
{{ $pagesByYear := .Pages.GroupByDate "2006" }}
{{ range $pagesByYear }}
<h2>Year: {{ .Key }}</h2>
<ul>
{{ range .Pages }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a> ({{ .Date.Format "Jan 2" }})</li>
{{ end }}
</ul>
{{ end }}
Why “2006”? Because that’s how Go’s date formatting works. Don’t ask me why they didn’t use strftime conventions like every other language; it’s one of those charmingly idiosyncratic choices you learn to live with. The month is 1, the day is 2, and the year is 6 (or 2006 for four digits). Just memorize it. It’s easier than fighting it.
A more common and useful pattern is grouping by year and month for a full archive:
{{ $pagesByMonth := .Pages.GroupByDate "2006-01" }}
{{ range $pagesByMonth }}
<h2>{{ .Key }}</h2> {{/* Will look like "2024-03" */}}
{{ range .Pages }}
{{/* Your page listing here */}}
{{ end }}
{{ end }}
Pitfall Alert: The time.Time key is in UTC. If you need to format it in a specific timezone, you have to do the conversion yourself on the .Key. It’s a bit of a hassle, and a common rough edge.
Grouping by Section
BySection is the simplest of the bunch, and it does exactly what you’d expect: it groups pages by their top-level section (the first element of their path). This is perfect for building a site index or a navigation page that breaks down content by its category.
{{ $pagesBySection := .Pages.GroupBySection }}
{{ range $pagesBySection }}
<h2>Section: {{ .Key }}</h2> {{/* .Key is a string, like "blog" or "products" */}}
<ul>
{{ range .Pages }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
The .Key here will be the directory name (e.g., blog), not the human-friendly title. If you want the pretty title, you’ll need to use site.GetPage to fetch the section page object inside your loop: {{ with site.GetPage .Key }}{{ .Title }}{{ end }}. It’s an extra step, but it works.
Grouping by Any Front Matter Parameter
This is where things get really interesting. ByParam is your gateway to custom, complex content organization. Want to group products by vendor, team members by department, or projects by client? This is your function.
The key here is that the front matter parameter you use must exist and should be consistent across the pages you’re grouping. If a page is missing the parameter, it will be silently excluded from all groups. Not placed in an “Unknown” group—just gone. This is the most common “gotcha.”
{{/* Group pages by the 'client' front matter parameter */}}
{{ $pagesByClient := .Pages.GroupByParam "client" }}
{{ range $pagesByClient }}
<h2>Client: {{ .Key }}</h2> {{/* .Key is the value of the 'client' param, e.g. "Acme Corp" */}}
<ul>
{{ range .Pages }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
For parameters that are themselves arrays, like tags or categories, you’d use GroupByParam’s more powerful sibling, GroupByParamValues. This one is a bit mind-bendy because a single page can appear in multiple groups.
{{/* Group pages by each of their 'tags' */}}
{{ $pagesByTag := .Pages.GroupByParamValues "tags" }}
{{ range $pagesByTag }}
<h2>Tag: {{ .Key }}</h2>
<ul>
{{ range .Pages }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
Best Practice: Always, always lowercase and sanitize your group keys if you plan to use them in URLs or for comparisons. GroupByParam "client" might return groups for “Acme Corp”, “acme corp”, and “Acme Corp.” which are all the same human-readable thing but three different groups to Hugo. I usually do the grouping first, then use a scratch or dict to create a new map grouped by a sanitized key. It’s a bit more code, but it prevents a total mess on the front end.
So there you have it. These three functions are how you move from a linear list to a structured, meaningful organization of your content. Use them wisely, watch for the edge cases, and your templates will be infinitely more powerful.