Distinguishing Collection Sets from Category Collections
A single boolean flag in the frontmatter separates navigational collection nodes from publishable photo sets — keeping the archive clean without adding structural complexity.
Category: Development
When I started building the archives page, two kinds of nodes in the photo tree suddenly had to be told apart — and in the filesystem they looked identical. Without an explicit signal, the archive would end up listing navigation aids as if they were finished works.
This post documents the choice I made: a single boolean flag in the frontmatter, why I didn’t reuse publishDate as a proxy, and how the filter ends up reading.
The problem
The collection tree has two kinds of nodes that look identical in the filesystem but serve completely different purposes.
A node like travels/asia/ is purely organisational. It groups cities under a region but isn’t something I’d point someone to as a finished work. A node like travels/asia/chiang-mai/ is a real photo set — curated, dated, and ready to stand on its own.
Both are index.md files. Both live in the same hierarchy. Without an explicit signal, there’s no clean way to tell them apart.
The archives page aggregates all published content into a chronological timeline — articles, notes, links, and now photo collections. The intent is to show things worth revisiting, not every internal navigation node. Showing Travels or Asia in the archive alongside Chiang Mai would be noise. Those nodes don’t have standalone value; they’re structure.
Implementation
Problem: The schema for collections_photos didn’t distinguish navigation nodes from real sets.
Implementation: A single boolean field in the schema:
const collections_photos = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
location: z.string().optional(),
publishDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
coverImage: z.string().optional(),
order: z.number().int().default(0),
set: z.boolean().default(false),
}),
});
It defaults to false, so existing category nodes need no changes. Only the actual photo sets carry set: true:
---
title: Chiang Mai
description: Street scenes and quiet moments from Chiang Mai.
location: Chiang Mai, Thailand
publishDate: 2025-10-06T11:04:00+01:00
set: true
---
travels/index.md and asia/index.md stay untouched. No set field, no publishDate — they remain what they are: navigation aids.
Solution: The filter in the archives page is unambiguous:
const photoSets = (
await getCollection(
"collections_photos",
({ data }) => data.set && !data.draft && !!data.publishDate,
)
).map((c) => ({
title: c.data.title,
date: c.data.publishDate!,
url: `/photos/collections/${collectionSlug(c)}`,
type: "photos" as const,
}));
Both conditions must hold: the node must be flagged as a set, and it must have a publish date. Either alone isn’t enough.
Why not infer from publishDate?
The publishDate field was already present on leaf collections and absent on category nodes, which makes it a tempting proxy. Filter on !!publishDate and you’d get the same result today.
The problem is semantic drift. publishDate means “when this was published”. Using its presence as a proxy for “this is a real set” gives the field two meanings. It also creates a subtle trap — adding a date to a category node for some future purpose would silently promote it to the archive.
An explicit set: true says what it means. The two flags also serve different authors in different moments: you might create a collection node and start adding photos over weeks before it’s ready to publish. set declares intent; publishDate declares readiness. Keeping them separate makes the draft workflow cleaner.
What changed
Photo sets appear in the archive alongside articles and notes, grouped by year, with a Photo Collection type badge. Category collections never appear. New collections added in the future are opt-in — the default is invisible until explicitly marked as a set.
What to take away
- One boolean beats overloading an existing field.
set: truemeans exactly one thing; repurposingpublishDatewould mean two. - Default to the safer value. With
setdefaulting tofalse, a forgotten flag keeps a node out of the archive rather than accidentally promoting it. - Intent and readiness are different axes. Declaring that something is a set is not the same as declaring it’s publishable —
setandpublishDatecompose cleanly. - The filter reads like the requirement.
data.set && !data.draft && !!data.publishDateis the whole rule, in one line, and matches how I’d describe it in prose.