Photo Albums with Astro's Content Layer

Category: Development

Tags: astro, photography


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/mdx installed.
  • Album sources under src/content/photos/albums/<album>/, each containing an .md file plus an img/ folder with JPGs and Vision-generated JSON sidecars.
  • Existing stream at /photos that 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:

RouteFile
/photos/albumssrc/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.

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 — .mdx without @astrojs/mdx silently disappears. Stick to .md unless 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 from astro: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 getStaticPaths shapes.