---
title: Structuring Photos as a Collection Tree
description: How the photos section grew from flat albums into a recursive collection hierarchy with nested routes, breadcrumbs, and a tag browsing system.
pubDate: '2026-03-21T10:31:00+01:00'
category: en/development
tags:
  - astro
  - photography
seriesParent: building-the-photo-stream
seriesOrder: 2
---

The original photos section had a flat structure — a list of albums, each containing photos. That worked for a handful of trips but fell apart once destinations started nesting. Asia isn't an album, it's a region containing cities, each of which is its own collection.

So I restructured the section into a recursive collection tree, and built a tag browsing system on top of the same data.

## The setup

- **Astro 6** content layer, no `@astrojs/mdx`.
- **New root**: `src/content/photos/collections/` — each directory with an `index.md` is a collection node; photos sit in a sibling `img/` folder.
- **Route**: a single `[...slug].astro` renders every collection page and every photo detail page.

## The folder structure mirrors the URL

Every directory with an `index.md` is a collection node. Photos live in an `img/` subdirectory directly inside that node.

```
src/content/photos/collections/
├── travels/
│   ├── index.md
│   └── asia/
│       ├── index.md
│       ├── img/                  ← photos belonging to "Asia" directly
│       ├── chiang-mai/
│       │   ├── index.md
│       │   └── img/
│       └── singapore/
│           ├── index.md
│           └── img/
```

The URL for a collection is its directory path:
`/photos/collections/travels/asia/chiang-mai`

A photo inside that collection is:
`/photos/collections/travels/asia/chiang-mai/2025-10-06-121017`

No configuration. No slug mapping. The filesystem is the route.

## Content collection for metadata only

Astro's `getCollection` only loads the `index.md` files — not the images or sidecars:

```ts
const collections_photos = defineCollection({
  loader: glob({
    pattern: "**/index.{md,mdx}",
    base: "./src/content/photos/collections",
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    location: z.string().optional(),
    order: z.number().int().default(0),
    draft: z.boolean().default(false),
  }),
});
```

This gives clean access to collection metadata without touching the photo files. The entry `id` for `travels/asia/chiang-mai/index.md` is exactly `travels/asia/chiang-mai/index.md`, which strips cleanly to a slug:

```ts
function collectionSlug(entry: CollectionEntry<"collections_photos">): string {
  return entry.id
    .replace(/\/index\.mdx?$/, "")
    .replace(/^index\.mdx?$/, "");
}
```

## One route file handles everything

**Problem:** With a recursive tree you need both collection index pages (grids of child cards + own photos) and photo detail pages, at every depth. Splitting those across two route files would duplicate a lot of layout and slug logic.

**Implementation:** A single `[...slug].astro` generates all routes. `getStaticPaths` emits two prop shapes:

```ts
type CollectionProps = { type: "collection"; slug: string; title: string; … };
type PhotoProps     = { type: "photo"; sidecar: PhotoSidecar; image: ImageMetadata; … };
```

For each collection, it emits one collection path plus one path per photo:

```ts
for (const col of allCollections) {
  const slug = collectionSlug(col);
  const photos = buildCollectionPhotos(sidecars, imageModules, slug);

  paths.push({ params: { slug }, props: { type: "collection", … } });

  photos.forEach((photo, i) => {
    paths.push({
      params: { slug: `${slug}/${photo.sidecar.id}` },
      props: { type: "photo", … },
    });
  });
}
```

**Solution:** The template renders conditionally on `props.type`. No separate files, no duplicated layout code.

## Loading photos for a collection node

`buildCollectionPhotos` loads only the photos directly inside a given node's `img/` directory — not recursively:

```ts
function buildCollectionPhotos(sidecars, imageModules, slug): LoadedPhoto[] {
  const prefix = `/src/content/photos/collections/${slug}/img/`;

  return Object.entries(sidecars)
    .filter(([p]) => p.startsWith(prefix) && !p.slice(prefix.length).includes("/"))
    .map(([jsonPath, sidecar]) => {
      const imgPath = jsonPath.replace(/\.json$/, ".jpg");
      const image = imageModules[imgPath]?.default;
      if (!image) return null;
      return { sidecar, image };
    })
    .filter((p): p is LoadedPhoto => p !== null)
    .sort((a, b) => new Date(a.sidecar.date).getTime() - new Date(b.sidecar.date).getTime());
}
```

This keeps photos scoped to their node. A collection page shows its own photos plus child collection cards — never grandchild photos inline.

## Breadcrumbs

Every collection page gets a breadcrumb trail built from the slug segments:

```ts
function buildBreadcrumbs(slug, allCollections): Breadcrumb[] {
  const crumbs = [{ label: "Collections", href: "/photos/collections" }];
  const segments = slug.split("/");
  for (let i = 0; i < segments.length; i++) {
    const partialSlug = segments.slice(0, i + 1).join("/");
    const entry = allCollections.find((c) => collectionSlug(c) === partialSlug);
    crumbs.push({
      label: entry?.data.title ?? segments[i],
      href: `/photos/collections/${partialSlug}`,
    });
  }
  return crumbs;
}
```

The result for `travels/asia/chiang-mai`:
`Collections / Travels / Asia / Chiang Mai`

## Tag browsing

Each photo sidecar already carries a `tags` array from Vision. Two new pages make them navigable.

`/photos/tags` aggregates all tags across all sidecars, counts occurrences, and renders them as pills sorted alphabetically:

```ts
const tagMap = new Map<string, { label: string; count: number }>();
for (const sidecar of Object.values(sidecars)) {
  for (const tag of sidecar.tags) {
    const slug = tagToSlug(tag);
    const entry = tagMap.get(slug);
    if (entry) entry.count++;
    else tagMap.set(slug, { label: tag, count: 1 });
  }
}
```

`/photos/tags/[slug]` filters all photos to those carrying the given tag and renders them in the same justified-layout grid used by the stream and collection pages. The photo links still point to the canonical collection URL — the tag page is a view, not a second home for the photo.

Tags in the `PhotoDetail` component became `<a>` links at the same time, so every tag pill on a detail page navigates directly to that tag's filtered grid.

The slug normalisation is consistent across all three files:

```ts
function tagToSlug(tag: string): string {
  return tag.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
}
```

## The one gotcha: import.meta.glob and the dev server

**Problem:** When new files are added to a directory already covered by a glob pattern, `getStaticPaths` in the dev server may not pick them up until a restart. The collection page renders fine — it calls `buildCollectionPhotos` fresh on each request — but `getStaticPaths` is cached from the last server start. The visible symptom: thumbnails show up on the collection page but the detail page 404s.

**Solution:** Restart the dev server. No code change needed — it's a Vite behaviour. Worth knowing before spending time chasing a routing bug.

## What to take away

- Let the filesystem be the route. `index.md` per directory plus a `[...slug].astro` gives you a recursive tree without any slug-to-path configuration.
- One catch-all route file with two prop shapes beats two parallel route trees — the template just branches on `props.type`.
- Scope photo loading per node (don't recurse): a collection shows its own photos plus child cards, never grandchild photos inline.
- `import.meta.glob` results in `getStaticPaths` are cached for the dev server session. New files require a restart to appear as routes — not a bug, just Vite.
- A single `tagToSlug` helper referenced from every file that generates a tag URL keeps tag pages from drifting out of sync.
