1 min read
Content collections: the feature that keeps a blog honest
Typed frontmatter sounds like busywork until the day a typo silently drops a post from your index.

Here’s a failure mode I’ve hit more than once: you write a post, it renders fine, and it never shows up in the list. The cause is one missing character in the frontmatter — date written as published, or a draft flag spelled differently. The page builds. The index query returns nothing. Silence.
Content collections fix this by moving frontmatter from “stringly typed” to an actual schema.
The schema
// src/content.config.ts
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog/en' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
Now a missing title fails the build with a useful message instead of producing a page with an empty heading. z.coerce.date() accepts 2026-09-28 and hands you a real Date, so sorting has nothing to guess about.
Two collections for two languages
I run English and Chinese as separate collections rather than one collection with a lang field. It costs a little duplication in the config, and buys a lot of simplicity in the pages:
export const collections = {
'blog-en': defineCollection({ /* ... */ }),
'blog-zh': defineCollection({ /* ... */ }),
};
Each language’s index queries only its own collection, so there’s no filtering to forget, and no chance of a Chinese post appearing on the English index because someone forgot a predicate.
What I put in the schema
Only fields the templates actually read. Every field you add is a field you have to fill in on every post, and a field that can drift out of sync with what the page uses. title, description, date, draft, tags, cover — that’s it. Reading time and excerpts are derived from the body at build time, not stored.
The rule I follow: if a value can be computed from the post, don’t ask the author to type it.