- Created a new post on Image Voice Memos detailing a macOS app for browsing photos and recording voice memos with automatic transcription. - Added a guide for Initial VPS Setup on Debian covering system updates, user creation, and SSH hardening. - Introduced a post on caching webmention avatars locally at build time to enhance privacy and comply with CSP. - Documented the implementation of security headers for an Astro site behind Caddy, focusing on GDPR compliance and CSP. - Set up a Forgejo Actions runner for self-hosted CI/CD, detailing the installation and configuration process for automated deployments.
5.8 KiB
| title | description | pubDate | category | tags | seriesParent | seriesOrder | ||
|---|---|---|---|---|---|---|---|---|
| Photo Albums with Astro's Content Layer | How the albums section is structured — from content collection to justified grid, album-scoped photo routes, and a sub-nav to switch between stream and albums. | 2026-03-19T09:23:00+01:00 | en/development |
|
building-the-photo-stream | 1 |
The photos section of this site had a single view for a while: a chronological stream of every image across every location. That works as a photostream but makes it hard to follow a specific trip. I wanted album pages on top of the existing stream without rebuilding the stream itself.
The setup
- Astro 6 with the content layer — no
@astrojs/mdxinstalled. - Album sources under
src/content/photos/albums/<album>/, each containing an.mdfile plus animg/folder with JPGs and Vision-generated JSON sidecars. - Existing stream at
/photosthat renders all sidecars in one justified grid — untouched by this change.
Content structure
Each album is a folder:
src/content/photos/albums/
chiang-mai/
chiang-mai.md ← metadata + editorial text
img/
2025-10-06-121017.jpg
2025-10-06-121017.json ← Vision sidecar
...
phuket/
phuket.md
img/
...
The .md file is registered as a content collection called photos in content.config.ts:
const photos = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: "./src/content/photos/albums",
}),
schema: z.object({
title: z.string(),
description: z.string(),
location: z.string().optional(),
publishDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
}),
});
One non-obvious detail — the files must be .md, not .mdx. The project doesn't use @astrojs/mdx, so the glob loader has no handler for .mdx files and silently skips them. The collection appears empty with no error beyond a [WARN] No entry type found in the server log. Worth knowing before you spend twenty minutes staring at a blank listing page.
Route structure
Three pages handle the albums section:
| Route | File |
|---|---|
/photos/albums |
src/pages/photos/albums/index.astro |
/photos/albums/[album] |
src/pages/photos/albums/[album]/index.astro |
/photos/albums/[album]/[id] |
src/pages/photos/albums/[album]/[id].astro |
The individual photo detail page at [album]/[id] keeps the user inside the album context. Prev/next navigation steps through photos in that album only, sorted by date. The back link in the middle of the pagination bar returns to the album grid.
Albums listing page
Problem: The listing page needs a cover image per album — but the album .md file doesn't carry one, and I didn't want to hand-pick covers in frontmatter for every new album.
Implementation: Read all non-draft entries from the photos collection, then use import.meta.glob to pick the first image from each album folder as a cover:
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/albums/**/*.jpg",
{ eager: true },
);
const covers = Object.fromEntries(
albums.map((album) => {
const folder = album.id.split("/")[0] ?? album.id;
const cover = Object.entries(imageModules)
.filter(([p]) => p.includes(`/albums/${folder}/`))
.sort(([a], [b]) => a.localeCompare(b))[0];
return [folder, cover?.[1]?.default ?? null];
}),
);
The album id from the glob loader is a path like chiang-mai/chiang-mai, so .split("/")[0] gives the folder name.
Solution: Covers are chosen by filename sort — my filenames start with the capture date, so the earliest photo of the trip becomes the cover. No frontmatter field required.
Album detail page
The album detail page renders the markdown body from the .md file alongside the photo grid. In Astro's content layer, render() is a standalone function imported from astro:content, not a method on the entry:
import { getCollection, render } from "astro:content";
const { Content } = await render(album);
Photos are loaded with import.meta.glob, filtered to the current album folder, and sorted by date. Each photo links to its album-scoped detail route:
<a href={`/photos/albums/${folder}/${photo.sidecar.id}`}>
The justified grid is the same layout engine used in the stream — justified-layout from Flickr, driven by aspect ratios and a target row height of 280px, positioned absolutely within a container whose height is set by the layout result.
Sub-nav
Both /photos and /photos/albums share a small sub-nav that lets you switch between stream and albums view. The active link is hardcoded per page:
<!-- on /photos -->
<a href="/photos" class="sub-nav__link is-active">Stream</a>
<a href="/photos/albums" class="sub-nav__link">Albums</a>
<!-- on /photos/albums -->
<a href="/photos" class="sub-nav__link">Stream</a>
<a href="/photos/albums" class="sub-nav__link is-active">Albums</a>
What stays separate
The original stream at /photos and the single-photo route at /photos/[id] are untouched. The albums section is an additive layer — same images on disk, different entry points and navigation context.
What to take away
- The content layer's glob loader only handles extensions you have integrations for —
.mdxwithout@astrojs/mdxsilently disappears. Stick to.mdunless you need MDX. - Pick album covers by filename sort when your filenames are date-prefixed; it beats adding a
cover:frontmatter field you have to maintain. - In Astro's content layer,
render()is imported fromastro:content— it's not a method on the entry any more. - Additive album routes on top of an existing flat stream cost very little — same JPGs, same sidecars, just different
getStaticPathsshapes.