---
title: 'Photo Albums with Astro''s Content Layer'
description: 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.
pubDate: '2026-03-19T09:23:00+01:00'
category: en/development
tags:
  - astro
  - photography
seriesParent: building-the-photo-stream
seriesOrder: 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/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`:

```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:

```ts
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:

```ts
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:

```ts
<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:

```html
<!-- 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.
