research/typed-content-collections-for-a-research-section
Typed Content Collections in Astro for a Research Section
Engineering Note · October 23, 2026 · 6 minute read · Astro · Content · TypeScript
An Astro content collection is a folder of content files, usually markdown, that Astro loads through a loader and validates against a zod schema at build time. A research section is mostly a pile of markdown files, and a pile of files drifts. One post gets a misspelled field, another forgets its date, a third ends up with tags as a string instead of a list. Typed content collections turn that drift into a build error. This is how we set up the collection behind our Research section, and what we reproduced when we broke it on purpose.
Versions used for the runs below: Node 22.22.2 and Astro 5.18.2, in a scratch project with build.format set to 'file'.
What does the collection look like?
The collection lives in the content config. A glob loader points at the folder of markdown files, and a zod schema describes the frontmatter:
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const research = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/research' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).default([]),
section: z
.enum(['Featured Research', 'Engineering Notes'])
.default('Engineering Notes'),
}),
});
export const collections = { research };
Three choices in that schema are worth explaining.
z.coerce.date() accepts the plain year-month-day value you naturally type in frontmatter and hands the page a real Date. Writing dates as strings by hand is fine; the schema does the conversion once, in one place.
tags defaults to an empty array, so a post with no tags still has something safe to loop over. Pages never need an “is this defined” check.
section defaults to Engineering Notes. The common post, a practical note, needs no section line at all. Only the longer featured pieces say so explicitly. Adding a rare field to the rare post keeps the common post short.
How do the list and post pages read the collection?
The list page asks for the whole collection, filters by section, and sorts newest first:
const posts = (await getCollection('research'))
.filter((post) => post.data.section === 'Engineering Notes')
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
Because the schema is typed, post.data.section and post.data.date are known to the editor and the type checker. Misspell a field name here and you find out before the page renders.
Each post page generates its paths from the same collection. The slug is the entry’s id, which comes from the file name:
---
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('research');
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
render(post) returns the Content component, which the template drops into the article layout. That is the whole pipeline: a file becomes a route, the route renders markdown, and the layout stays in one template. In the scratch project, four markdown files produced dist/research/<id>.html for each, with build.format: 'file'.
What does a schema violation look like? Reproduced build errors
We broke the schema five ways, one file at a time, and ran npx astro build. The table shows the exit code and the field line Astro printed. Every failure also begins with [InvalidContentEntryDataError] research → <file> data does not match collection schema.
| Frontmatter mistake | Exit code | Error line |
|---|---|---|
description omitted | 1 | description: Required |
date: next tuesday | 1 | date: Invalid date |
tags: Astro (a string, not a list) | 1 | tags: Expected type "array", received "string" |
section: Engineering Note (typo) | 1 | section: Invalid enum value. Expected 'Featured Research' | 'Engineering Notes', received 'Engineering Note' |
descripton: misspelled, so description missing | 1 | description: Required |
extra key authr: Gary | 0 | none, the build passed |
The error also prints a Location: line with the path of the offending markdown file, so the person who just wrote the post knows where to look.
Failure modes we reproduced
Unknown keys are dropped silently. The last row above is the surprise. A plain z.object strips keys it does not know, so a misspelled optional field never errors; it simply never arrives. We added .strict() to the schema and ran the same file:
[InvalidContentEntryDataError] research → extra-key data does not match collection schema.
****: Unrecognized key(s) in object: 'authr'
The schema printed in this note does not use .strict(), so it has the behavior in the last table row. The tradeoff of .strict() is that adding a new frontmatter field to any post now requires adding it to the schema in the same commit, which is the same discipline we want anyway.
Dates shift by one day in western time zones. A date-only frontmatter value is parsed as midnight UTC. Formatting it in the build machine’s or viewer’s local time zone can print the previous day. We parsed one value with z.coerce.date() and formatted it with and without timeZone: 'UTC':
TZ | Default zone | timeZone: 'UTC' |
|---|---|---|
UTC | January 1, 2000 | January 1, 2000 |
America/Los_Angeles | December 31, 1999 | January 1, 2000 |
Asia/Tokyo | January 1, 2000 | January 1, 2000 |
This is the kind of bug nobody reports, because it is off by one day and only in some places. We format with timeZone: 'UTC' so the date printed is the date typed:
post.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
How is reading time calculated?
Reading time is the word count of the post body divided by 220, rounded up, with a minimum of one minute. It is an estimate and we present it as one. The boundaries, computed with Math.max(1, Math.ceil(words / 220)):
| Words | Minutes |
|---|---|
| 0 | 1 |
| 1 | 1 |
| 220 | 1 |
| 221 | 2 |
| 441 | 3 |
| 1000 | 5 |
Why should the build fail?
If a post’s frontmatter violates the schema, the build stops and names the file and the field. That is the point. A static site has no moment later when a missing description gets caught by a user; the only checkpoint is the build. A loud failure there means the broken post never ships, and the person who just wrote it is the one who sees the error.
It also changes what publishing means. Adding an article is adding one markdown file. There is no database row, no admin screen, no list to update by hand. The file appears on the list page, gets its own route, and carries its own metadata, all because it passed the same schema as every post before it. It fits the approach we wrote about in the case for boring web architecture: fewer moving parts, and the ones that remain are checked.
Checklist
- Put the frontmatter shape in a zod schema, not in a style guide.
- Consider
.strict()so a misspelled optional key fails instead of vanishing. - Use
z.coerce.date()and format dates in UTC. - Give optional fields defaults so pages don’t branch on missing values.
- Derive routes from the collection with
getStaticPaths, and use the entryidas the slug. - Treat a failing build as the feature, not an obstacle.
Related reading
- The Case for Boring Web Architecture
- Structuring an Astro Static Site with Cloudflare Pages Functions
- Documentation as Leverage: Runbooks and Decision Logs for Small Companies
Frequently asked questions
What is a content collection in Astro?
A content collection is a folder of content files, usually markdown, loaded through defineCollection with a loader and an optional zod schema. Astro validates each file's frontmatter against the schema at build time and gives pages typed data through getCollection. Each file becomes an entry whose id comes from its file name.
What happens when frontmatter does not match the zod schema in Astro?
The build stops with an InvalidContentEntryDataError that names the collection, the file and the failing field, for example description: Required, and the process exits with code 1. We reproduced this with a missing description, an invalid date, a string where an array was expected, and a bad enum value.
Does Astro reject unknown frontmatter keys?
Not by default. With a plain z.object schema, we added a misspelled key to a post and the build still passed with exit code 0, because zod strips unknown keys. Adding .strict() to the schema made the same file fail with Unrecognized key(s) in object, so a typo like authr is caught.
How should I format a frontmatter date in Astro so it does not show the wrong day?
Use z.coerce.date() in the schema and format with timeZone set to UTC. A date-only value parses as midnight UTC, so formatting in a western time zone shows the previous day. We ran the same date under three time zones: only America/Los_Angeles printed the day before.