Adding Notes, Links, and Archives
Three new content sections built on Astro content collections — short-form notes, a curated link log, and a unified chronological archive.
Category: Development
Once Articles and Photos were in place, the site still felt one-dimensional — long-form or nothing. I wanted a place for short observations, a home for the URLs I actually want to keep, and a single chronological timeline across everything.
This post documents how I added three sections — Notes, Links, and Archives — each mapped to a distinct content shape and a different reading pattern.
The setup
- Astro 6 static site, content living entirely under
src/content/. - Content Collections with Zod schemas —
posts,notes,links, and the existingcollections_photos. - Goal: a lightweight short-form section, a curated link log, and a single archive that merges all of it chronologically — no runtime queries, no API routes.
Notes
Problem: Articles are the wrong shape for a one-paragraph observation or a quick reference. I wanted something between “tweet” and “blog post” — informal Markdown that still lives on my domain.
Implementation: A minimal collection schema:
notes: defineCollection({
schema: z.object({
title: z.string(),
publishDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
}),
The index at /notes renders every note inline rather than linking to a summary. Each note is passed through render() at build time and the resulting Content component is embedded directly in the list:
const renderedNotes = await Promise.all(
notes.map(async (note) => ({
note,
Content: (await render(note)).Content,
})),
);
Solution: The page becomes a continuous scroll through all notes — no click required to read them. Individual pages at /notes/[slug] still exist for direct linking and sharing.
Links
Problem: I already had a habit of stashing interesting URLs in scratch files. The goal was a public, curated log — title, URL, source, tags — without it turning into a database project.
Implementation: A data collection with just enough schema to be useful:
links: defineCollection({
type: "data",
schema: z.object({
title: z.string(),
url: z.string().url(),
date: z.coerce.date(),
description: z.string().optional(),
via: z.string().optional(),
tags: z.array(z.reference("tags")).default([]),
}),
}),
Using type: "data" means entries live as .json or .yaml files in src/content/links/ — no Markdown body needed. The via field records where the discovery came from. Tags reference the shared tags collection, so labels stay consistent across Articles and Links.
The index strips the www. prefix for a cleaner display:
function getDomain(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
Solution: Tag filtering is handled by a static route at /links/tag/[slug], generated from all referenced tags at build time. Adding a link is editing one file.
Archives
Problem: With four content types — Articles, Notes, Links, photo collections — the site needed a single place to see everything that had ever been published, newest-first, without hiding things inside their own silos.
Implementation: The archive is the one page that doesn’t belong to a single collection. It pulls from all four sources, normalises them into a common shape, and sorts everything:
const all = [...posts, ...notes, ...photoSets, ...links].sort(
(a, b) => b.date.valueOf() - a.date.valueOf(),
);
The common shape is just four fields: title, date, url, and type. type drives the colour-coded label on each row — Article, Note, Photo Collection, Link — so the origin of an entry is visible without opening it.
Entries are then grouped by year:
const byYear = new Map<number, typeof all>();
for (const entry of all) {
const year = entry.date.getFullYear();
if (!byYear.has(year)) byYear.set(year, []);
byYear.get(year)!.push(entry);
}
const years = [...byYear.keys()].sort((a, b) => b - a);
Solution: Each year becomes its own <section> with a bold separator. The date column shows only day and month — the year heading makes the year redundant.
What stays the same
All three pages share the same layout skeleton as Articles: BaseLayout for meta and OG tags, Nav and Footer top and bottom, a centred single-column main capped at 680px. No new layout component was introduced.
Content lives entirely in src/content/. The build reads it, validates it against the schema, and generates static HTML. No runtime queries, no API routes.
What to take away
- Content collections scale past articles. A minimal schema per shape — notes, links, photo sets — is cheaper than bending one collection to fit everything.
type: "data"collections are a good fit for bookmarks. No Markdown body means editing a link is editing one field.- A shared
tagscollection across collections keeps labels consistent. Referencing beats free-form strings the moment you want tag pages. - The archive is a view, not a collection. Normalising four sources into four fields is enough — anything more is duplication.
- Rendering notes inline on the index removes a click that was never adding value.