# Content Collections

Course: [Astro](https://www.devtutorials.ai/learn/astro) · Section: Content
Source: https://www.devtutorials.ai/learn/astro/content/content-collections

> What collections are, src/content.config.ts, defineCollection(), the glob() and file() loaders, entry IDs, Zod schemas with optional fields and defaults, getCollection() with filters and sorting, getEntry(), render() and the Content component, and JSON Schema support.

## Content Collections

- A blog with 50 posts has 50 frontmatter blocks. One typo, such as `titel:` instead of `title:`, can break a page.
- A **content collection** is a named group of similar content, such as `blog` or `authors`.
- You tell Astro where the files are and what fields each one must have.
- Astro checks every file at build time, and gives you a simple way to read them.
- In this lesson, Asha turns her posts into a checked `blog` collection.

**Simple lesson:** a collection is a group of content that Astro loads, checks and serves to your pages.

### A Real-World Example

- Ravi writes a post and types `pubDate: next week`.
- With plain Markdown files, the page builds, and shows "Invalid Date" to readers.
- With a collection, the **build stops** and says which file and which field is wrong.
- Asha fixes it before any reader sees it.

Press **▶ Run**. The home page reads the collection. Then open `src/content.config.ts`.

<ul>
    {posts.map((post) => (
      <li>
        {post.data.title}
        <small> · {post.data.pubDate.toDateString()} · {post.data.tags.length} tags</small>
      </li>
    ))}
</ul>
`,
  }}
/>

What happened:

- `content.config.ts` defined a collection called `blog`.
- `glob()` found the two `.md` files.
- The schema turned `pubDate` into a real `Date`. The second post got `tags: []` by default.
- The **Terminal** shows how many entries were loaded.

### What You Will Learn

In this lesson, you will learn:

1. What collections are, and when to use one.
2. How to define a collection in `src/content.config.ts`.
3. The `glob()` and `file()` loaders.
4. How entry IDs are made.
5. How to write a Zod schema with optional fields and defaults.
6. How to get, filter and sort entries.
7. How to render an entry's Markdown with `render()`.

## What Are Content Collections?

### Types of Collections

- **Build-time collection** = loaded once, when the site is built. This lesson covers these.
- **Live collection** = loaded on every request, for data that changes all the time. You meet them in **Fresh Data at Scale**.

### When to Use a Collection

Use one when:

- you have many items of the same shape: posts, products, authors, docs pages,
- you want each item checked before the site is built,
- the content lives in files (Markdown, JSON, YAML) or comes from an API.

You do not need one for:

- one-off pages, such as the home or about page,
- small data used in one place.

A good sign you need a collection: you are copying the same frontmatter fields into a third file.

### Collection Entries

- **Entry** = one item in a collection, such as one blog post.
- Each entry has:
  - `id`: its unique name,
  - `data`: the checked frontmatter or JSON fields,
  - `body`: the raw Markdown text, for Markdown entries,
  - `collection`: the collection's name.

<figure className="ast-fig ngc-fig">
<svg viewBox="0 0 660 220" role="img" aria-labelledby="fig-coll" style={{ width: "100%", maxWidth: 660, height: "auto" }}>
  <title id="fig-coll">Animation: Markdown files go through the loader, then the schema check. Good files become entries. A bad file stops at the schema with a red cross.</title>
  <defs><marker id="cl-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L10 5 L0 10 z" style={{ fill: "var(--ngc-slate)" }} /></marker></defs>
  <g style={{ fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>
    <rect x="16" y="40" width="120" height="28" rx="6" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} /><text x="76" y="59" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>why-astro.md</text>
    <rect x="16" y="80" width="120" height="28" rx="6" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} /><text x="76" y="99" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>tips.md</text>
    <rect x="16" y="120" width="120" height="28" rx="6" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)" }} /><text x="76" y="139" textAnchor="middle" style={{ fill: "var(--ngc-red)" }}>typo.md</text>
  </g>
  <rect x="180" y="70" width="110" height="50" rx="10" style={{ fill: "var(--ngc-purple)" }} />
  <text x="235" y="100" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700 }}>glob()</text>
  <rect x="330" y="70" width="120" height="50" rx="10" style={{ fill: "var(--ngc-orange)" }} />
  <text x="390" y="92" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Zod schema</text>
  <text x="390" y="110" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 11 }}>checks every field</text>
  <g style={{ fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>
    <rect x="500" y="50" width="140" height="28" rx="6" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)" }} /><text x="570" y="69" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>✓ entry: why-astro</text>
    <rect x="500" y="88" width="140" height="28" rx="6" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)" }} /><text x="570" y="107" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>✓ entry: tips</text>
    <text x="390" y="160" textAnchor="middle" style={{ fill: "var(--ngc-red)" }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.55;0.6;0.95;1" dur="4s" repeatCount="indefinite" />✗ typo.md: pubDate is not a date. Build stops.</text>
  </g>
  <path d="M136 94 H176" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#cl-arrow)" />
  <path d="M290 95 H326" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#cl-arrow)" />
  <path d="M450 90 L496 66" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#cl-arrow)" />
  <path d="M450 98 L496 102" style={{ stroke: "var(--ngc-slate)", strokeWidth: 2, fill: "none" }} markerEnd="url(#cl-arrow)" />
  <circle r="6" style={{ fill: "var(--ngc-blue)" }}><animateMotion dur="4s" repeatCount="indefinite" path="M136 54 L180 95 H450 L496 64" /></circle>
  <circle r="6" style={{ fill: "var(--ngc-red)" }}><animateMotion dur="4s" begin="1.2s" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" path="M136 134 L180 95 H380" /></circle>
</svg>
<figcaption>Every file goes through the loader and the schema. A bad file stops the build.</figcaption>
</figure>

**Simple lesson:** use a collection for many items of the same shape. Each item becomes an entry with `id` and `data`.

## Defining Collections

### src/content.config.ts

- All collections are defined in one file: `src/content.config.ts` (or `.js`).
- It must export an object called `collections`.

### defineCollection()

- `defineCollection({ loader, schema })` describes one collection.
- `loader` = where the entries come from.
- `schema` = the shape every entry must have. It is optional, but you should always add one.

### The glob() Loader

- `glob()` loads entries from files: Markdown, MDX, JSON or YAML.
- `base` = the folder to search. `pattern` = which files to take.
- `glob({ pattern: "**/*.md", base: "./src/content/blog" })` takes every `.md` file in that folder and its sub-folders.

### The file() Loader

- `file()` loads many entries from **one** file, such as a JSON list.
- Each object in the list needs an `id` field.

### Entry IDs and Custom IDs

- With `glob()`, the ID is made from the file path, in lower case, with no extension.
  - `why-astro.md` → `why-astro`
  - `2026/My Post.md` → `2026/my-post`
- Set your own ID with a `slug` field in the frontmatter.
- With `file()`, the ID is each object's `id` field.

Press **▶ Run**. `authors` comes from one JSON file. `blog` comes from many Markdown files.

<ul>{posts.map((p) => <li><code>{p.id}</code> → {p.data.title}</li>)}</ul>

<h2>Authors</h2>
<ul>{authors.map((a) => <li><code>{a.id}</code> → {a.data.name}, {a.data.city}</li>)}</ul>
`,
  }}
/>

- The file `2026/Year In Review.md` got the ID `2026/year-in-review`.

**Simple lesson:** `glob()` for many files, `file()` for one file with many items. IDs come from file names or `id` fields.

## Schemas

### Zod Schemas

- **Schema** = a description of the fields an entry must have, and their types.
- Astro uses **Zod**, a popular checking library. Import it with `import { z } from "astro/zod";`.
- `z.object({ ... })` lists the fields.

### Common Field Types

- `z.string()` = text.
- `z.number()` = a number.
- `z.boolean()` = `true` or `false`.
- `z.coerce.date()` = a date. "Coerce" means "turn the text into a `Date` if possible".
- `z.array(z.string())` = a list of text.
- `z.enum(["draft", "published"])` = one of a fixed set of values.

### Optional Fields and Default Values

- `.optional()` = the field may be missing. Its value is then `undefined`.
- `.default(value)` = the field may be missing. Its value is then `value`.
- Example: `draft: z.boolean().default(false)`.

### [TS] Inferred Types from Schemas

- Astro turns your schema into TypeScript types.
- In your editor, `post.data.pubDate` is known to be a `Date`, and `post.data.tags` a `string[]`.
- Spelling mistakes, such as `post.data.titel`, are underlined before you build.
- **[JS]** You still get the check at build time. Your editor may also suggest field names.

Press **▶ Run**. One post has a mistake, so the build stops. Read the **Terminal**, then fix the post.

`,
  }}
/>

- The error names the entry (`broken`) and each wrong field.
- Fix `titel` to `title`, and `ready` to `published`. Then run again.

Use `z.coerce.date()` for dates. YAML may read `2026-11-10` as text, and `coerce` turns it into a real `Date` you can sort and format.

**Simple lesson:** a Zod schema lists the fields. A wrong or missing field stops the build with a clear message.

## Querying Collections

### getCollection()

- `await getCollection("blog")` returns every entry as a list.
- It runs at build time, in the component script.

### Filtering Entries

- Pass a function as the second argument. Keep the entries where it returns `true`.
- `getCollection("blog", ({ data }) => !data.draft)` leaves out drafts.

### Sorting Entries

- `getCollection()` does not promise any order. Sort the list yourself.
- Newest first: `posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())`.

### getEntry() and getEntries()

- `await getEntry("blog", "why-astro")` returns one entry by its ID, or `undefined`.
- `await getEntries([...])` returns several entries from a list of references. You use it in **Pages from Content**.

Press **▶ Run**. Drafts are left out, and the newest post comes first.

<ol>{posts.map((p) => <li>{p.data.title}</li>)}</ol>
<p>Featured: {featured?.data.title}</p>
`,
  }}
/>

**Simple lesson:** `getCollection()` for lists, with a filter and your own sort. `getEntry()` for one entry.

## Rendering Entries

### render() and the Content Component

- `entry.data` has the frontmatter. The Markdown body is not HTML yet.
- `const { Content, headings } = await render(entry);` turns it into HTML.
- Use `<Content />` in the template where the post's text should appear.

### Rendering Headings

- `headings` is the same list as in a Markdown layout: `depth`, `slug` and `text` for each heading.
- Use it to build a table of contents.

### [TS] Typing Entries with CollectionEntry

- `CollectionEntry<"blog">` is the type of one blog entry.
- Use it for a component's props: `interface Props { post: CollectionEntry<"blog"> }`.

```ts
import type { CollectionEntry } from "astro:content";

interface Props {
  post: CollectionEntry<"blog">;
}
```

<p>Sections: {headings.map((h) => h.text).join(" → ")}</p>
<article>
</article>
`,
  }}
/>

## Editor Support

### JSON Schema Files for Data Entries

- For JSON and YAML entries, Astro writes a **JSON Schema** file for each collection into the `.astro/collections/` folder.
- Point your JSON file to it with a `"$schema"` field. VS Code then checks the file and suggests field names as you type.

In this lab, collections are loaded by a small version of Astro's content layer that runs in your browser. It uses the real Zod and Astro's real Markdown package. On your computer, Astro also caches entries between builds in the `.astro` folder.

**Simple lesson:** `render()` turns an entry's Markdown into a `<Content />` component and a list of headings.

## TL;DR

1. **Collection** = a named group of similar content, checked at build time.
2. **src/content.config.ts** = `export const collections = { blog, ... }`.
3. **defineCollection()** = one collection: `defineCollection({ loader, schema })`.
4. **Loaders:**
   - `glob({ pattern, base })` = many files,
   - `file("path.json")` = many items in one file.
5. **Entry** = `id`, `data`, `body`, `collection`.
6. **Schema** = `z.object({...})` from `astro/zod`. Use `.optional()`, `.default()`, `z.coerce.date()`.
7. **getCollection(name, filter)** = all entries. Sort them yourself.
8. **getEntry(name, id)** = one entry.
9. **render(entry)** = `{ Content, headings }`.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Not exporting `collections` | Defining is enough | `export const collections = { blog }` | `getCollection("blog")` finds nothing |
| Wrong `base` folder | The path is relative to the config | Start from the project root: `./src/content/blog` | Zero entries loaded |
| `z.date()` for a text date | YAML dates are always dates | Use `z.coerce.date()` | The schema rejects the date |
| Expecting sorted results | `getCollection()` sorts by date | Sort the list yourself | Posts show in a random order |
| Using `post.body` as HTML | The body is ready HTML | Use `render()` and `<Content />` | Raw Markdown shows on the page |
| No schema | Schemas are only for TypeScript | Always add one | Typos reach the live site |

</div>

## Check Yourself

## Exercises

## What to Remember

- A collection groups content of the same shape. Define it in `src/content.config.ts`.
- `glob()` reads many files, `file()` reads one file with many items.
- A Zod schema checks every entry and stops the build on mistakes.
- `getCollection()` with a filter and a sort, `getEntry()` for one item.
- `render()` gives you `<Content />` and `headings`.

Next: **Pages from Content**, where every post gets its own page, with pagination and tags.
