
If you're not into that, maybe skip this one! (What's this?)
One of the key features I wanted to build for this website was a custom gallery. I previously had a basic one on Wordpress - it was entirely manually managed through their visual block editor. Huge hassle, and very slow and awkward. Eventually I did actually build a database-driven Wordpress plugin to automate it, which turned into a custom page type, but then before I ever put it live, I got fed up with Wordpress and decided to leave the whole ecosystem.
On the other hand, building my gallery in Hugo has been pretty straight forward. The gallery was one of the first things I did in Hugo, so I was learning the tool as I went, and yet it still came together rather quickly! And that’s also considering that I spent ages faffing around with the stylesheets.
I don’t open source my site code, for an element of mystique (by which I really mean I don’t want my draft posts public). But I do want to share some snippets to help anyone who’s trying to build something similar. This isn’t a tutorial, nor a complete explainer of the gallery - it’s more a collection of notes documenting my approach and choices. Please reach out if you want to know any more details!
My feature requirements
From the outset, I wanted to build:
- A gallery view showing images and some data about each project.
- Individual project pages including more data.
- Multiple data fields per project:
- Common fields like name, completion date (month and year), pattern, pattern designer, and materials used.
- Optional fields like related links (blog posts, Instagram, external sites), where the materials came from, and my own notes.
- Ease of adding more fields later.
- Tags, categories, and years - all of which can be used to filter the gallery view.
- Front page of the gallery shows the three latest images, to prevent unnecessary loading of images when landing on the gallery.
- There is an “all” filter to show all years.
Out of scope for MVP
- Multiple images per project, and carousel displays.
- More than one pattern per project, e.g. pattern mashups, or sets.
- More than materials description per project, e.g. if I used two different fabrics.
- Specific knitting fields like needle size, yarn colourway, etc. (My existing gallery didn’t have these either.)
How I built it with Hugo
NB. I am going to assume that you have a rudimentary knowledge of Hugo.
These are the design decisions I made:
- All projects are directories, and their images are stored with them.
- Years are managed as directories. This has the side benefit of helping me to organise my project files, as I have ~150 projects, and growing. It also gives me the year filtering for free (ish - more on that later).
- Custom taxonomies of
gallery_tags
andgallery_categories
, to differentiate from the tags and categories on the blog.
Let’s dig into it.
Directory structure
This is what my structure looks like:
content
└── gallery
├── 2023
│ ├── 2023-03-01-ankara-helmi-top
│ │ ├── index.md // Project content and data
│ │ └── helmi.jpg // Project photo
│ └── _index.md // Section index for the year
├── _index.md // Section index for the gallery
└── all
└── _index.md // Section index for "all"
There’s already a lot going on here, so let’s have a look.
The project directory name contains the date
content
└── gallery
├── 2023
│ ├── 2023-03-01-ankara-helmi-top
│ │ ├── index.md
│ │ └── helmi.jpg
│ └── _index.md
├── _index.md
└── all
└── _index.md
Prefixing the project directory name with the completion date of the project helps me organise my files chronologically. The date gets stripped out of the title and slug automatically by the archetype (more on that later).
On the presentation layer, I only display the month and year (because for older projects that’s all the data I have). The day is actually arbitrary and is just used for sort order.
Years are directories, and so is “all”
content
└── gallery
├── 2023
│ ├── 2023-03-01-ankara-helmi-top
│ │ ├── index.md
│ │ └── helmi.jpg
│ └── _index.md
├── _index.md
└── all
└── _index.md
In order to be able to filter the gallery by year, I’ve created directories for each year. The idea here is that you can visit /gallery/2023/ and it will show you the projects from 2023. I’ve also created an “all” directory, which will show projects from every year.
But this alone isn’t enough - I also needed to set up some data and tweak the template.
1) _index.md in the year directory
Each year directory contains an _index.md
file to signal that it is a section. And that file needs to specify the gallery
type to match the parent section. This tells Hugo to use the gallery list template when it renders this page. (Bonus: this file also gives me a space to enter some commentary on each year, in the content section at the bottom.)
/content/gallery/2023/_index.md:
+++
title = '2023'
sort_key = '2023'
type = 'gallery'
+++
In 2023 I finished a total of 19 sewing projects [...]
Speaking of templates…
2) The gallery list template needs to search directories recursively
The standard way to list pages in Hugo is with .Pages
, but this only goes one level deep. To recurse through the subdirectories, I needed to use .RegularPagesRecursive
. However… I’m using this same list template for three key pages, which display different things:
/gallery
(latest 3 projects)/gallery/[year]
(projects from that year)/gallery/all
(projects from all years)
A little bit of conditional logic is needed to determine the base page from which to recurse, and whether to only display the top 3:
/layouts/gallery/list.html:
{{- if eq .Path "/gallery" }}
<h2>Latest Projects</h2>
{{- range .RegularPagesRecursive | first 3 }}
{{ .Render "gallery_card" }}
{{- end }}
{{- else }}
{{- $basePage := .Page }}
{{- if eq .Path "/gallery/all" }}
{{- $basePage = .Parent }}
{{- end }}
{{ range (.Paginate $basePage.RegularPagesRecursive).Pages }}
{{ .Render "gallery_card" }}
{{- end }}
{{- end }}
Creating the menu links for the years
I keep the same list of links at the top of each list page, starting with “Latest” (which is just /gallery), iterating through each of the years, and finishing with “All” at the end. This seems fairly straight forward on the surface, but I hit an unexpected quirk while rendering it.
/layouts/gallery/list.html:
{{- if eq .Path "/gallery" }}
Latest</span>
{{- else }}
<a href="{{ relURL "/gallery" }}">Latest</a>
{{- end }}
{{- $thisTitle := .Page.Title }}
{{- $galleryRoot := .Site.GetPage "gallery" }}
{{ range sort $galleryRoot.Sections ".Params.sort_key" "desc" }}
<span>•</span>
{{ if eq $thisTitle .Params.title }}
<span>{{ .Params.title }}</span>
{{- else }}
<span><a href="{{ relURL .Path }}">{{ .Params.title }}</a></span>
{{- end }}
{{ end }}
Notice that I’m using .Params.sort_key
to order the years (you might have spotted that in my 2022 _index.md
example earlier). Why do I need to do this, when I just want them to display in order of title? Can’t I sort by the title, rather than having to define a new field?
Well, there’s a weird quirk in the sorting of my particular directory names. This is what happened when I sorted by title:
2024
2023
2022
2021
2020
2019
All
2018 and Earlier
Eh??
Turns out lexicographical sorting means that the combination of letters and numbers in “2018 and Earlier” gets treated differently from the number-only strings like “2018”. To be clear, they’re all strings. I even tried casting them to strings. Something is going on under the hood here to sort them in a way that I did not expect.
Anyway, there’s probably a cleaner solution than this, but using a sort key was an easy enough fix.
Tags & categories
I set up new taxonomies called gallery_tags
and gallery_categories
to differentiate from the tags and categories used in the blog. This works absolutely fine out of the box, but I wanted to tweak a few things.
1. Changing the taxonomies’ display names
I didn’t want these taxonomies showing up as “Gallery Tags” all over the site. “Tags” and “Categories” is sufficient. So I created the directories /content/gallery_tags
and /content/gallery_categories
, adding index.md
files in which I could specify titles in the front matter as with any other page. Again, I could add some content to these pages here if I wanted to.
2. Positioning the taxonomies in the hierarchy
I wanted the gallery taxonomies to show under the Gallery in the breadcrumb. Like this:
Instead of the default:
To do this I injected an extra link into the basic loop of page ancestors, in the case of taxonomies. I did the same for my blog tags, so they have the same hierarchical structure (tags show under Blog). Here’s the breadcrumb code now:
/layouts/partials/breadcrumbs.html:
{{- range .Ancestors.Reverse | append .Page}}
{{- if eq .Kind "taxonomy" }}
{{ $parentPath := index .Site.Params.parents .Section }}
{{ $parent := .Site.GetPage $parentPath }}
<li><a href="{{ $parent.RelPermalink }}">{{ $parent.Title }}</a></li>
{{- end }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{- end }}
It’s relying on some custom params that I stuck in my site configuration to define the hierarchy:
/hugo.toml:
[params.parents]
gallery_tags = "/gallery"
gallery_categories = "/gallery"
3. Fixing the taxonomies’ permalinks
I also added some config to make the URLs mirror the breadcrumb hierarchy:
/hugo.toml:
[permalinks.taxonomy]
gallery_tags = "/gallery/tags/"
gallery_categories = "/gallery/categories/"
[permalinks.term]
gallery_tags = "/gallery/tags/:slug"
gallery_categories = "/gallery/categories/:slug"
The gallery archetype
The archetype in Hugo is the template from which new content is created. I use this to set up a few default values.
/archetypes/gallery.md:
+++
type = 'gallery'
{{- $nameWithoutDate := replaceRE "^\\d{4}-\\d{2}-\\d{2}-" "" .Page.File.ContentBaseName }}
{{- $dateFromName := replaceRE "(^\\d{4}-\\d{2}-\\d{2})-.*" "$1" .Page.File.ContentBaseName }}
date = '{{ $dateFromName }}'
title = '{{ replace $nameWithoutDate "-" " " | title }}'
+++
Yes, it’s NUTS, but I wouldn’t be me if I didn’t try to sneak in a little regex somewhere.
All it’s really doing is taking the directory name and extracting the date and title into their correct locations. In combination with a tweak to the permalink in the site config, this gives me nice URLs and titles that don’t have any date information in them at all, while maintaining my date-based file organisation. (I have the exact same set up on the blog.)
/hugo.toml:
[permalinks]
gallery = "/gallery/:title/"
Storing the project data
From here, it’s pretty straight forward stuff. All the project data is stored in front matter:
+++
draft = false
type = 'gallery'
date = '2024-11-01'
title = 'Clair De Lune Sweater'
gallery_categories = ['knitting']
gallery_tags = ['the petite knitter']
[params.project_data]
completed_date = '2024-11-01'
pattern_name = 'Clair de Lune'
pattern_designer = 'The Petite Knitter'
materials = 'Drops Alaska'
materials_from = 'Wool Warehouse'
[[resources]]
name = 'mainImage'
src = 'clairdelune.jpg'
+++
Notes go here, like content on any other page.
I use that mainImage
resource to access the cover image from the gallery list page, so I can display those cute cards.
(NB. Currently I have completed_date as a separate field from the project date, as the publish date mught be different for projects that I can only share a while after I’ve made them - gifts, pattern tests etc.)
Displaying the gallery content
I have a custom template for the gallery list and gallery single. It’s straightforward stuff - reading the parameters out of the front matter - so I’m not going to go into any detail. The styling took me absolutely ages, as I’m not a CSS expert by a long way - but if you want to be nosy about that, you can go and view source directly!
Adding a new project to the gallery
Adding a new project looks like this:
$ hugo new content content/gallery/2024/2024-11-01-clair-de-lune-sweater
I add my photo into the directory, edit the generated index.md
, and there she goes!

Conclusion
I really enjoyed building this, and I’m enjoying using it even more. If this info was useful to you at all, I’d love to hear about it. I don’t have a contact form here yet, but you can drop me a line on Instagram. I’d also really like to hear any comments or suggestions you might have on my approach!