I wanted to have a place to show my photos, as bad as they are, and while services like SmugMug and Flickr might function wonderfully, they are centralised and closed source, walled gardens. I already have a site/blog built with Hugo, so why not stick with my “keep everything under your own control” mentality and stash them there?
I do like the simple structure of Flickr, where you can have a bunch of albums, and each album will have a bunch of photos, so I’ll aim to reproduce that.
Here’s how I went about it. This document will contain many links to clarify things, and I might edit it further should things need further explanation.
Requirements
- Some photos, for example exported from DarkTable
- A site running Hugo, of course. Getting that running is outside the scope of this little howto, but I’ll link to the relevant documentation where relevant.
- Some knowledge of CSS and Javascript really comes in handy…
If you want to dive right in (and are comfortable reading Hugo-centric code in a git repo), here’s the situation for my site at the moment of writing.
The section
What I basically did, was create a new section, and add some templates to display the pages in that section differently than the rest of the content.
I created the section fotos (“foto’s” is Dutch for photos), by adding content/fotos/_index.md in my site structure, with the minimum content:
---
title: Foto's
---
This will make Hugo render a new page (under /photos/) with the specified title. More importantly, it’ll consider all pages underneath that directory as listable pages inside that section.
The photo albums
Each photo album is a page bundle, which means it’s a directory containing an index.md file, and one or more images. I chose to chuck the images in a directory called images/, but that’s not strictly neccesary. So a photo album is a page, and the images are that page’s resources.
Mind the difference between _index.md and index.md, one starts a section, the other one is a page.
So, now my site structure looks a bit like this:
+/ site root
+- content/
| +- index.md # The home page
| +- about.md # More words
| +- archief/ # My blog posts (the "archief" section)
| | + some-blog-post/
| | +- index.md # ...and so on
| +- fotos/ # The "fotos" section
| | +- _index.md # Naming the section
| | +- some-album/
| | | +- index.md # This is one album
| | | +- images/
| | | +- 20250723-001.jpg
| | | +- 20250723-002.jpg
| | | +- 20250723-003.jpg
| | +- another-album/
| | | +- index.md
| | | +- images/
| | | + 20250725-001.jpg
| | | + 20250725-002.jpg
| | | + 20250725-003.jpg
+- layouts/
| +- _default/ # Everything in here is "the default"
| | +- baseof.html
| | +- list.html
| | +- single.html
| +- fotos/ # Templates for photo pages
| | +- list.html
| | +- single.html
| +- partials/
| | +- list-albums.html
+- public/ # This is where stuff gets rendered to
I started with further subdivisions, so I would have /content/fotos/foo/bar/, where foo was another section, but I decided to keep things slightly more simple.
So at this point I have content in this directory, which gets rendered to this page.
Adding a photo album
Adding a new photo album is a simple as creating a directory structure with one Markdown file and one or more images:
+/ site root
+- content/
| ...
| +- fotos/
| | ...
| | +- a-new-album/
| | +- index.md
| | +- images/
| | +- 20250726-001.jpg
| | +- 20250726-002.jpg
| | +- 20250726-003.jpg
| | ...
Let Hugo do its rendering song and dance, and hey presto, new page. Depending on how many photos you have in there, and how big they are (I downscale everything to fit within 1500×1500 pixels when exporting from DarkTable), it shouldn’t take too long.
The layout
The neat thing about sections is that Hugo will look for section-specific templates, falling back to the default ones if they’re not present. Since I want to display photos different from a bucket of words, I use these specific templates.
A note about template locations
While writing this post and looking up documentation pages to link, I came upon this one, which describes a change in the template structure. I’m on a slightly older version of Hugo that doesn’t require this new structure yet, if you do, you’ll have to shuffle things around and place them in the right spots.
To do that, I created the fotos/ directory inside /layouts/ and crafted specific layouts for lists and single posts in there. The lists is used for the list of albums (so this renders /fotos/ itself), and single is used for each individual album page.
Because I wanted to be able to display a few albums on the home page, in addition to showing all the items on the /fotos/ page, I split off the actual display bit into a partial. If you only want to display them in one spot, you can inline that bit of code.
The markup
Rendering the album pages is actually not that difficult (well, relatively speaking): in the page template, you grab the images (resources) that belong with that page, sort them by name descending (I name them with the ISO-formatted date and time, so that results in a list with the newest image on top), and loop through them to render the thumbnails:
{{ $items := sort (.Page.Resources.ByType "image") "Name" "desc" }}
<section id="photo-thumbnails">
{{ range $index, $item := $items }}
{{- $resizedImage := $item.Process (print "resize 400x") -}}
{{- $src := $resizedImage.RelPermalink }}
<figure style="aspect-ratio: {{ .Width }} / {{ .Height }}">
<a href="#f-{{ path.BaseName .RelPermalink }}" data-thumb-index="{{ $index }}">
<img src="{{ $src }}">
</a>
</figure>
{{ end }}
</section>
Each thumb is resized to 400px wide (and whatever height), and stuffed inside a link, indside a <figure> element (with the aspect ratio of that image baked in). I need this structure later on for CSS and Javascript.
The “big” versions of the images (the lightbox, so to speak), uses the same list of $items and loops over it again:
<section class="lightboxes">
{{ range $index, $item := $items }}
<figure class="lightbox" id="f-{{ path.BaseName .RelPermalink }}" data-thumb-index="{{ $index }}">
<img src="{{ $item.RelPermalink }}">
<figcaption>
{{ with $item.Exif }}
{{ with .Tags.Title }}{{ . }}{{ end -}}
{{ end }}
</figcaption>
</figure>
{{ end }}
</section>
Each figure.lightbox defaults to display: none and gets shown when you click its thumbnail, come past it with previous or next keys, of when you open the page with the filename in the URL fragment (so /fotos/album-name/#f-<image-name-without-extension>).
You see that
f-as a prefix for the id? That’s because my filenames usually start with the date, and you can’t have an ID property that starts with a number.
More Exif tags?
It’s totally possible to add more Exif tags to the output, provided you have them baked into your images during export. I had to dive into the export preferences in DarkTable and add some metadata items to get them out:
Exif.Photo.ImageTitle→$(TITLE)Exif.Photo.UserComment→$(TITLE)Exif.Photo.LensModel→$(LENS)
It seemed to pick up the rest (camera model, exposure settings and so on) by itself.
Then you can add the camera and lens to the output, within the with $item.Exif block, like this
{{- with .Tags.Model }}{{ . }}{{ end -}}
{{- with .Tags.LensModel }} + {{ . }}{{ end -}}
You can find my full photo templates here.
The CSS
In terms of CSS, I render these items using a flexbox-layout. This results in a flexible list of items that will fill rows automatically. I might end up with a wider-than-usual thumbnail in the last row, but I can live with that.
The relevant code for my site starts here (the main.is-photo-page part, ending at line 1021). The most important rules are flex-flow: row wrap on the flex container, and flex: 2 0 auto on the children.
The Javascript
The photo-centric Javascript for my site lives here.
The first iteration of the albums actually didn’t use any Javascript. It relied on CSS only, following the process outlined here. But I wanted to add keyboard navigation and less browser history pollution, so I did switch to a Javascript solution. It basically does the same thing the CSS-only method did, but with more effort. ![]()
I can now move between photos with arrow left/right (and vim equivalents) and close a fullscreen lightbox with Escape.
The end
That concludes the howto, for now. Hopefully, this is of use to people, or at least something inspirational. If there’s things you want to know more about, feel free to ask!
Now all I need to do is go and take actually decent photos…