Photo Albums with Astro's Content Layer
The photos section of this site had a single view: a chronological stream of all images across every location. That works as a photostream but makes it hard to follow a specific trip or place.
This post covers how the albums section was added on top of the existing stream.
Content structure
Albums live in src/content/photos/albums/. 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 important detail: the files must be .md, not .mdx. The project does not 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.
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
The listing page reads all non-draft entries from the photos collection, then uses 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.
Album detail page
The album detail page renders the markdown body from the .md file alongside the photo grid. In Astro 5’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, different entry points and navigation context.