Content Collections: Structured Content with Type Safety

Chapter 5
Content Collections: Structured Content with Type Safety
defineCollection, Zod schemas, getCollection(), and an honest look at what this doesn't solve

Astro's own answer to "structured, validated content" is Content Collections — a real, first-class feature for organizing Markdown/MDX content into typed, schema-validated groups, kept in src/content/.

Defining a Collection

// src/content/config.ts import { defineCollection, z } from 'astro:content'; const blog = defineCollection({ type: 'content', schema: z.object({ title: z.string(), publishDate: z.date(), tags: z.array(z.string()).optional(), }), }); export const collections = { blog };

The schema is a real Zod object — Zod is a popular TypeScript-first schema validation library, used here to declare exactly what fields every entry's own frontmatter must have, and of what type. Every Markdown file in src/content/blog/ gets validated against this schema.

Type safety is a build-time guarantee, not a suggestion
If a blog post's frontmatter is missing title, or writes publishDate as plain text instead of a real date, Astro reports a real, build-time type error — not a silent runtime bug discovered later. TypeScript also infers the exact shape of every entry's own data object directly from this schema, so autocomplete and type-checking work correctly anywhere a collection entry is used in code.

Reading a Collection: getCollection()

// src/pages/blog/index.astro --- import { getCollection } from 'astro:content'; const posts = await getCollection('blog'); --- <ul> {posts.map((post) => <li>{post.data.title}</li>)} </ul>

getCollection('blog') returns every entry in the collection, each with a validated, typed data object matching the Zod schema above.

Rendering a Single Entry — Connecting Back to getStaticPaths()

// src/pages/blog/[slug].astro --- import { getCollection } from 'astro:content'; export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.slug }, props: { post }, })); } const { post } = Astro.props; const { Content } = await post.render(); --- <h1>{post.data.title}</h1> <Content />

Chapter 3's own getStaticPaths() can return a props object alongside params — whatever's passed here becomes directly available as Astro.props in the page, avoiding a second lookup. post.render() returns a real <Content /> component rendering that entry's own Markdown body.

Honest limit: this doesn't solve arbitrary-depth trees on its own
Content Collections model flat or shallow grouped content well — a blog collection, a docs collection — but there's no built-in mechanism for a genuinely self-referencing, arbitrary-depth tree the way a real database and ORM provide. Zod's own reference() helper can link one entry to another (a parent field referencing a sibling entry), but that's a schema-level cross-reference, not the same thing as the adjacency-list-plus-materialized-path hybrid every course in the Website Rebuild series independently arrived at. A future Website Rebuild with Astro course would genuinely need to choose between building that reference pattern by hand within Content Collections, or reaching for database-backed content in server output mode (Chapter 10) instead — not assume Content Collections alone already solve the same problem.

Coding Challenges

Challenge 1

Define a docs collection with a Zod schema (title: string, order: number), add at least three content files, and use getCollection() to list all entries sorted by order.

📄 View solution
Challenge 2

Build a dynamic route rendering a single docs entry's own Content, passing the entry through getStaticPaths()'s props rather than looking it up a second time.

📄 View solution
Challenge 3

Deliberately break one content file's frontmatter (e.g. write order as text instead of a number), run the dev server, and report the exact build-time type error Astro produces.

📄 View solution

Chapter 5 Quick Reference

  • src/content/config.ts — defines every collection and its own Zod schema
  • defineCollection({ schema: z.object({...}) }) — validated, typed frontmatter
  • getCollection('name') — returns every validated, typed entry in a collection
  • post.render() — returns a <Content /> component for that entry's Markdown body
  • getStaticPaths()'s own props — passes data straight through, avoiding a second lookup
  • Honest limit — no built-in self-referencing arbitrary-depth tree; a future rebuild needs reference() or database-backed content instead
  • Next chapter: Styling in Astro