[LAB NOTE]

Edge-Native Content Collections

  • astro
  • cloudflare
  • content

Every site in the studio’s portfolio treats structured content the same way: Markdown files with a typed schema, validated at build time, compiled into a static artefact, and served from the edge with nothing to query at request time. This note is the pattern and the reasoning behind it.

Typed content with Zod

Astro’s Content Layer API lets you define a collection with a loader and a Zod schema. The loader points at a directory of Markdown; the schema describes the frontmatter.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const notes = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/notes' }),
  schema: z.object({
    title: z.string(),
    publishDate: z.coerce.date(),
    excerpt: z.string(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { notes };

Two details matter. z.coerce.date() accepts a bare YYYY-MM-DD string from YAML and hands your templates a real Date, so sorting and formatting are not string operations. And .default(...) means optional fields are genuinely optional in the file but always present in the typed data, which removes a layer of null-checking from every page.

A malformed value — a number where a string belongs, a missing required field — fails astro build with a message naming the file and the field. Bad content cannot ship. That is the entire point: the schema is a contract enforced by the build, not a convention enforced by discipline.

The Content Layer specifics

If your Astro knowledge predates version 5, a few things changed. The config file is src/content.config.ts at the project root, not src/content/config.ts. Collections take a loader, not type: 'content'. You query with getCollection('notes') as before, but to render a body you now import render from astro:content at the top level — const { Content } = await render(entry) — rather than calling entry.render(). And the per-entry identifier is entry.id, derived from the filename; there is no entry.slug any more.

Why static, why the edge

Once content is typed data, the build folds it into pre-rendered HTML. There is no content database in production, so a page view is a CDN cache hit. On Cloudflare Pages that means global low latency, a hosting cost that rounds to nothing, and no runtime to patch or scale. The few genuinely dynamic needs — a contact form, later maybe an agent endpoint — live in Pages Functions alongside the static assets, invoked only on the paths that need them.

Drafts get one small piece of build-time logic: filter draft === true entries only when import.meta.env.PROD, so unpublished notes are visible in astro dev and absent from the deployed site.

The shared helper

Sorting, filtering, reading-time, and date formatting get centralised in one module — src/lib/collections.ts here — that exports CollectionEntry-typed getters. Pages import getPublishedNotes() or getFeaturedProjects() and never touch getCollection directly. When the sort rule or the draft policy changes, it changes in one file, and the types catch every call site that assumed the old shape.

The result is content that behaves like a database to author and like a static file to serve.