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.
Breadcrumbs
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.