---
title: Adding Notes, Links, and Archives
description: Three new content sections built on Astro content collections — short-form notes, a curated link log, and a unified chronological archive.
pubDate: '2026-03-21T14:27:00+01:00'
category: en/development
tags:
  - astro
seriesParent: joining-the-indieweb
seriesOrder: 11
---

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 existing `collections_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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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 `tags` collection 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.
