So far, we have placed our Markdown (.md) files directly in the src/pages folder. This works well for small sites, where the file automatically created the route.
But this approach has serious problems when the project grows:
- Lack of Validation: If in one article you put
date: "2023-01-01"and in another you forget, or putfecha: "Enero", your sorting code will explode. - No Autocompletion: Your editor doesn’t know what data your Markdown has. You have to memorize if you called the field
image,img,cover, orthumbnail. - Mixing Concepts:
src/pagesshould be for routing, not for storing your content database.
Astro solves this with Content Collections.
Content Collections changed Astro forever in version 2.0. Before, managing a large blog was a minefield: if you misspelled a date or forgot to add the featured image in the frontmatter, the website could break in production or display empty data.
The src/content Folder
Astro reserves a special folder at the root of src called content.
Anything you put here will not automatically generate routes. That is, if you put src/content/blog/my-post.md, you won’t be able to access /blog/my-post just like that.
This is good, because it allows us to separate data (the content) from presentation (how it’s displayed).
A typical structure looks like this:
src/ ├── content/ │ ├── config.ts # Key file │ ├── blog/ # “blog” collection │ │ ├── post-1.md │ │ └── post-2.mdx │ └── authors/ # “authors” collection │ ├── luis.json │ └── ana.json
Configuration and Schemas with Zod
The definition of collections is done in the src/content/config.ts file. This is where we define the rules.
Astro uses Zod, a very popular TypeScript schema validation library, to ensure our frontmatter is perfect.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
// 1. Define the schema for the 'blog' collection
const blogCollection = defineCollection({
type: 'content', // 'content' for markdown/mdx, 'data' for json/yaml
schema: z.object({
title: z.string(),
description: z.string().max(160, "Description is too long for SEO!"),
pubDate: z.date(),
draft: z.boolean().default(false),
// Zod can validate that the image actually exists in your folder
cover: z.string().optional(),
tags: z.array(z.string()),
}),
});
// 2. Define the schema for the 'authors' collection
const authorsCollection = defineCollection({
type: 'data',
schema: z.object({
name: z.string(),
role: z.string(),
twitter: z.string().url(),
})
});
// 3. Export the collections to register them
export const collections = {
'blog': blogCollection,
'authors': authorsCollection,
};
If you now try to create a Markdown file in src/content/blog/ and forget the title, or put text in pubDate instead of a valid date… Astro won’t compile!
Instead, it will show you a huge red error in the terminal telling you exactly which file is wrong and why. (goodbye to errors in production).
Querying Collections (getCollection)
Since files in src/content don’t generate routes, we have to “request” them ourselves through code.
For this, we use the getCollection and getEntry functions from astro:content.
Imagine we want to display a list of articles in our index.astro:
---
// src/pages/index.astro
import { getCollection } from 'astro:content';
import Card from '../components/Card.astro';
// Get ALL blog posts
// We can filter directly inside the function
const posts = await getCollection('blog', ({ data }) => {
return data.draft === false; // Only show non-draft posts
});
---
<h1>My Blog</h1>
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>
{post.data.title}
</a>
<p>{post.data.description}</p>
</li>
))}
</ul>
Notice we access the values through the .data property. post.slug is the friendly URL of the file. post.body contains the raw content. post.data contains the validated frontmatter.
Generating Dynamic Routes
Now that we have the data, we need to create individual pages for each article (e.g., /blog/my-post).
For this, we use Astro’s Dynamic Routes in combination with collections. We create a file with brackets at src/pages/blog/[...slug].astro.
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
// 1. getStaticPaths is MANDATORY in static mode (SSG)
// It tells Astro which pages to generate
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post }, // Pass the entire post object as a prop
}));
}
// 2. We get the post from the props
const { post } = Astro.props;
// 3. Render the content
// This special function processes Markdown/MDX into HTML
const { Content } = await post.render();
---
<Layout title={post.data.title}>
<h1>{post.data.title}</h1>
<p>Published on: {post.data.pubDate.toLocaleDateString()}</p>
<article class="prose">
<Content />
</article>
</Layout>
- Astro executes
getStaticPathsduring the build. - Calls
getCollection('blog')and retrieves 100 articles. - Generates 100 routes:
/blog/article-1,/blog/article-2, etc. - For each route, it executes the component, renders the
<Content />, and saves the final HTML.
Relationships Between Collections
One of the most powerful and recent features is the ability to relate collections, similar to a relational database (SQL).
Imagine each blog post has an author, and that author is in the authors collection.
In src/content/config.ts:
const blogCollection = defineCollection({
schema: ({ image, reference }) => z.object({
title: z.string(),
// Reference the 'authors' collection by its ID (file name)
author: reference('authors'),
}),
});
And then, when rendering the post you can do:
---
import { getEntry } from 'astro:content';
const { post } = Astro.props;
// Retrieve the author's data using the reference
const authorData = await getEntry(post.data.author);
---
<p>Written by: {authorData.data.name}</p>
<a href={authorData.data.twitter}>Follow on Twitter</a>
