Structuring Photos as a Collection Tree

Category: Development

Tags: astro, photography


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:

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:

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:

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:

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:

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.

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

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:

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:

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.