Using Eleventy and S3 to build a personal photo blog
I wanted a nice place to display albums of photos I take (I'm not a great photogapher, but it's nice to be able to share them with friends). I don't use Instagram or Flickr, and websites like 500px seem intimidating. In any case, as soon as I upload a photo to a third party business, I lose control over it. I'd far rather just have a site where I put my photos up and unambiguously license them with a Creative Commons license so anyone can make use of them without a business getting in the way.
Also, I love making websites.
Here's what https://photos.raphaelkabo.com/ looks like with some albums from the last year:
The albums list, which is the home page.
An individual album page. Hi Mahesh!
There's even a lightbox!
I decided to build this site with the static site generator Eleventy, because I really like the way it combines modern JS/TS with the classic concepts (including templates, collections, and layouts) developed by veteran static site generators like Jekyll and Hugo. I could have used Astro, which I had previously used to generate my homepage and for client projects, but I also wanted to learn some new skills - and Astro felt a bit overkill for something as simple as my photo blog concept.
Using S3 for file storage
Planning ahead a little, I didn't want to store a whole bunch of binary files (that is, high-resolution photos) in a version control system like Git. Instead, I decided to store my photos in DigitalOcean's S3-compatible Spaces object storage. This isn't a DigitalOcean sponsored post by any means, but the simplicity of $5/month for 250GB of storage with huge bandwidth limits and a built-in CDN appeals to me. It makes even more sense because I'm using Spaces for a couple of other projects. Once the photos were uploaded to Spaces, my Eleventy site needed to somehow scoop them up, generate thumbnails from them during the build step, add some metadata, and keep track of the high-resolution URLs so I could show them in a lightbox.
Step 1: Set up Eleventy
Eleventy is refreshingly simple to set up. The file system tends to describe the output structure, so with this in my eleventy.config.js
, I could write the contents of my homepage, at index.njk
, the albums/[album].njk
page, and _includes/base.njk
:
module.exports = function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("favicon.png");
return {
dir: {
input: ".",
output: "_site",
includes: "_includes",
data: "_data",
},
};
};
Step 2: Connect to S3
I pull the photos in from S3 as an Eleventy collection. Under _data
, I created a YAML database of my albums. This file holds my album metadata, because the S3 directory contains only photos:
// _data/albumMetadata.yaml
albums:
- folderName: 2024-01-north-finchley
displayName: North Finchley, London
date: January 2024
coverImage: 4
description: Nikon D600, Nikon 35mm f/2.0 AF-D. Just a soft, quiet winter's day; I'm trying to develop some affection for this area by working on noticing. I think the winter light adds a lovely moodiness to the colours.
- folderName: 2024-04-around-london
displayName: Around London
date: January-April 2024
coverImage: 6
description: Nikon EM, 50mm f/1.8, Ilford HP5+ 400 and Kodak Gold 200
...
Next, I created an S3 client:
// util/s3Client.js
const { S3 } = require("@aws-sdk/client-s3");
const dotenv = require("dotenv");
dotenv.config();
const s3Client = new S3({
forcePathStyle: false, // Configures to use subdomain/virtual calling format.
endpoint: process.env.S3_ENDPOINT,
region: "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
});
module.exports = s3Client;
I adapted this client using the very decent DigitalOcean Spaces documentation.
Finally, we need a file which maps the photos into a format Eleventy recognises, which is at _data/albums.js
(this automatically makes the new collection accessible at albums
.)
const s3Client = require("../util/s3Client");
const yaml = require("js-yaml");
const fs = require("fs");
const path = require("path");
const dotenv = require("dotenv");
dotenv.config();
const albumMetadata = yaml.load(
fs.readFileSync(path.join(__dirname, "albumMetadata.yaml"), "utf8")
);
module.exports = async function () {
const params = {
Bucket: process.env.S3_BUCKET,
Delimiter: "/",
};
try {
const listAll = await s3Client.listObjectsV2(params);
let albums = listAll.CommonPrefixes.map((prefix) => {
return {
folderName: prefix.Prefix.slice(0, -1), // Remove trailing slash
path: prefix.Prefix,
};
});
// Fetch images for each album and merge with metadata
albums = await Promise.all(
albums.map(async (album) => {
const albumParams = {
Bucket: process.env.S3_BUCKET,
Prefix: album.path,
};
const albumContents = await s3Client.listObjectsV2(albumParams);
const images = albumContents.Contents.filter((item) =>
item.Key.endsWith(".jpg")
).map((item) => ({
key: item.Key,
url: `${process.env.CDN_URL}/${item.Key}`,
}));
// Find matching metadata
const metadata =
albumMetadata.albums.find(
(meta) => meta.folderName === album.folderName
) || {};
return {
...album,
...metadata,
images,
};
})
);
return albums.sort((a, b) => b.folderName.localeCompare(a.folderName));
} catch (error) {
console.error("Error fetching albums from DigitalOcean Spaces:", error);
return [];
}
};
Step 3: Use the collection in Eleventy
Every time Eleventy builds the site or runs in serve mode, it fetches the photos. On the home page, index.njk
, I can then run through each album to build a grid:
<ul class="album-list">
{% for album in albums %}
<li>
<a href="./albums/{{ album.folderName | slug }}/">
// Here I can use album.folderName, album.metadata, and album.images
</a>
</li>
{% endfor %}
</ul>
And on the individual album page, [album].njk
, I use Eleventy's amazing pagination feature to pull the collection in directly:
---
pagination:
data: albums
size: 1
alias: album
permalink: "albums/{{ album.folderName | slug }}/"
---
<header class="album-header">
<h1>{{ album.displayName or album.folderName }}</h1>
</header>
<div class="album-meta">
{% if album.date %}
<p class="album-date">{{ album.date }}</p>
{% endif %} {% if album.description %}
<p class="album-description">{{ album.description }}</p>
{% endif %}
</div>
You can see the full source code for this site, including the lightbox JavaScript, on SourceHut.
Notes
Thank you to Mark Llobera for explaining how to display Nunjucks templates in Markdown files without rendering them.