--- 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//`, 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 ``` 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 Stream Albums Stream Albums ``` ## 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.