Skip to main content
Ganesh Joshi
Back to Blogs

Astro content collections: type-safe markdown and MDX

February 15, 20265 min read
Tutorials
Code editor with markdown or content structure on screen

Astro content collections keep your markdown and MDX in a dedicated folder and validate them with a schema. You get TypeScript types for frontmatter and body, and a simple API to list and fetch entries. This avoids typos in frontmatter and keeps content and code in sync.

What are content collections?

Content collections are Astro's built-in system for managing content files:

Feature Benefit
Schema validation Catch frontmatter errors at build time
TypeScript types Autocomplete and type checking for content
Consistent API Same query methods for all collections
Build-time processing Content is validated and transformed during build
MDX support Use components in your content

Instead of manually parsing markdown and hoping frontmatter is correct, you define a schema and let Astro enforce it.

Setting up a content collection

1. Create the content folder

Create a folder under src/content/ for your collection:

src/
  content/
    blog/
      my-first-post.md
      getting-started.md
    config.ts

2. Define the schema

Create src/content/config.ts to define your collection schemas:

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    draft: z.boolean().default(false),
    author: z.string().default('Anonymous'),
    tags: z.array(z.string()).default([]),
    image: z.string().optional(),
  }),
});

export const collections = { blog };

3. Write content

Create markdown or MDX files in the collection folder:

---
title: "Getting Started with Astro"
description: "A beginner's guide to building with Astro"
pubDate: 2026-02-15
tags: ["astro", "tutorial"]
---

Your content here...

If frontmatter is missing required fields or has wrong types, the build fails with a clear error.

Querying content collections

Get all entries

import { getCollection } from 'astro:content';

// Get all blog posts
const allPosts = await getCollection('blog');

// Filter out drafts
const publishedPosts = await getCollection('blog', ({ data }) => {
  return data.draft !== true;
});

// Sort by date
const sortedPosts = publishedPosts.sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

Get a single entry

import { getEntry } from 'astro:content';

const post = await getEntry('blog', 'my-first-post');

if (post) {
  console.log(post.data.title); // Typed!
  console.log(post.body);       // Raw markdown
}

Render content

---
import { getEntry } from 'astro:content';

const post = await getEntry('blog', 'my-first-post');
const { Content } = await post.render();
---

<article>
  <h1>{post.data.title}</h1>
  <time>{post.data.pubDate.toLocaleDateString()}</time>
  <Content />
</article>

Dynamic routes with content collections

Create dynamic pages for each content entry:

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

Schema patterns

Required vs optional fields

schema: z.object({
  // Required
  title: z.string(),
  pubDate: z.coerce.date(),
  
  // Optional
  description: z.string().optional(),
  
  // Optional with default
  draft: z.boolean().default(false),
  tags: z.array(z.string()).default([]),
})

Enums for categories

schema: z.object({
  category: z.enum(['tutorial', 'guide', 'news', 'opinion']),
})

Image references

import { defineCollection, z, image } from 'astro:content';

const blog = defineCollection({
  schema: ({ image }) => z.object({
    title: z.string(),
    cover: image(), // Validates image exists in src/
  }),
});

Related content references

import { defineCollection, z, reference } from 'astro:content';

const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    relatedPosts: z.array(reference('blog')).optional(),
  }),
});

MDX and custom components

For MDX files, the schema applies to frontmatter. Pass custom components to the renderer:

---
import { getEntry } from 'astro:content';
import Callout from '../components/Callout.astro';
import CodeBlock from '../components/CodeBlock.astro';

const post = await getEntry('blog', 'advanced-guide');
const { Content } = await post.render();
---

<Content components={{ Callout, CodeBlock, pre: CodeBlock }} />

Now your MDX content can use <Callout> and custom code block rendering.

Multiple collections

Define multiple collections for different content types:

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
  }),
});

const docs = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    section: z.string(),
    order: z.number(),
  }),
});

const authors = defineCollection({
  type: 'data', // JSON/YAML data, not content
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string(),
  }),
});

export const collections = { blog, docs, authors };

Common patterns

Filter by tag

const posts = await getCollection('blog');
const reactPosts = posts.filter((post) => 
  post.data.tags.includes('react')
);

Group by category

const posts = await getCollection('blog');
const byCategory = posts.reduce((acc, post) => {
  const cat = post.data.category;
  acc[cat] = acc[cat] || [];
  acc[cat].push(post);
  return acc;
}, {} as Record<string, typeof posts>);

Pagination

export async function getStaticPaths({ paginate }) {
  const posts = await getCollection('blog');
  const sorted = posts.sort((a, b) => 
    b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
  
  return paginate(sorted, { pageSize: 10 });
}

Benefits over manual parsing

Manual approach Content collections
Parse frontmatter yourself Automatic parsing and validation
No type safety Full TypeScript types
Runtime errors for bad data Build-time errors
Custom query logic Consistent API
Easy to have inconsistent content Schema enforces consistency

Summary

Astro content collections provide:

  1. Schema validation with Zod for frontmatter
  2. TypeScript types for autocomplete and error checking
  3. Build-time validation catches errors early
  4. Simple query API with getCollection and getEntry
  5. MDX support with custom components

Define your schema once, and Astro ensures all content matches. The Astro content collections docs have the full API and advanced patterns.

Frequently Asked Questions

Content collections are Astro's way of organizing markdown and MDX files with a defined schema. They provide type-safe frontmatter, validation at build time, and a simple API to query content.

Content collections validate frontmatter against a schema, catch errors at build time, provide TypeScript types for autocomplete, and offer a consistent API for querying and filtering content.

Create src/content/config.ts and use defineCollection with a Zod schema for frontmatter fields like title, date, and draft. Astro validates every file against this schema at build time.

Yes. MDX files work the same as markdown in content collections. The schema applies to frontmatter, and you can pass custom components to the MDX renderer for your design system.

Use getCollection('blog') to get all entries or getEntry('blog', 'slug') for one. The returned objects are fully typed with your schema, giving you autocomplete and type checking.

Related Posts