← Home

Structuring Photos as a Collection Tree

The original photos section had a flat structure: a list of albums, each containing photos. That worked for a handful of albums 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.

This documents the restructuring to a recursive collection tree and the tag browsing system built alongside it.

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

A single [...slug].astro generates all routes — both collection index pages and individual photo detail pages. 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", … },
    });
  });
}

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 carries a tags array. 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, "");
}

One gotcha: import.meta.glob and the dev server

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 result is thumbnails visible on the collection page but 404s when clicking through to the detail page.

The fix is a restart. No code change needed; it’s a Vite behaviour. Worth knowing before spending time chasing a routing bug.

← Home