Astro Content Collections: Complete 2026 Guide

Master Astro's Content Layer API with Zod validation, remote loaders, and performance optimization. Complete migration guide with production-ready examples.

Inzimam Ul Haq
Inzimam Ul Haq
· 14 min read · Updated
Abstract space visualization representing Astro framework's content layer
Photo by NASA on Unsplash

Your Markdown frontmatter is a liability. One missing field, one wrong date format, and your build fails silently—or worse, deploys broken pages to production. I’ve shipped dozens of content-heavy Astro sites, and content collections have eliminated entire categories of bugs from my workflow.

Astro content collections are the framework’s built-in system for managing structured content with TypeScript type safety and Zod schema validation. With Astro 5.0’s Content Layer API, you can now load content from anywhere—local files, remote APIs, or headless CMS platforms—while enjoying 5x faster builds and 50% less memory usage than the legacy approach.

This guide covers everything from basic setup to production patterns, updated for Astro 5.0+.

TL;DR — 5 Things You Need to Know

  1. Config file location changed — Use src/content.config.ts (not src/content/config.ts) in Astro 5.0+
  2. Loaders replace type declarations — Use loader: glob() for files, loader: file() for JSON/YAML data
  3. Performance is dramatically better — 5x faster Markdown builds, 2x faster MDX, 25-50% less memory
  4. Remote content is now native — Custom loaders can fetch from any CMS, API, or database
  5. Run npx astro sync — Always regenerate types after changing your schema

What Are Astro Content Collections?

Content collections are Astro’s built-in way to manage sets of related content—blog posts, documentation pages, product listings, author profiles—with automatic validation and type inference. They replace manual file imports with a structured, type-safe API.

Here’s what content collections provide:

FeatureWithout CollectionsWith Collections
File LoadingManual import.meta.glob()getCollection() API
Frontmatter ValidationNone (runtime errors)Zod schema (build-time errors)
TypeScript SupportNo type inferenceFull auto-generated types
Error DetectionProduction/runtimeDevelopment/build time
Content CachingManual implementationBuilt-in optimization
Remote ContentCustom fetch logicPluggable loaders

The Content Layer API (Astro 5.0+)

Astro 5.0 introduced the Content Layer API, a complete rewrite of how collections work under the hood. For the official documentation, see the Astro Content Collections Guide. The key changes:

  1. Loaders replace type declarations — Instead of type: "content", you use loader: glob() or loader: file()
  2. Content lives anywhere — No longer restricted to src/content/; store files anywhere on your filesystem
  3. Remote content support — Custom loaders can fetch from any API, CMS, or database
  4. Massive performance gains — Up to 5x faster Markdown builds, 2x faster MDX, 25-50% less memory
Astro Content Layer architecture diagram showing data flow from sources (Markdown, MDX, JSON, Remote API) through loaders (glob, file, custom) into a cached data store, then queried via getCollection and getEntry to generate static HTML pages

Content Layer architecture: Sources → Loaders → Cached Data Store → Static Output

Migrating from Astro 2-4? The config file location changed from src/content/config.ts to src/content.config.ts. See the migration section below.

Setting Up Your First Content Collection

Let’s build a blog collection from scratch using the current Astro 5.0+ API.

Step 1: Create the Content Config

Create src/content.config.ts (note: this is in src/, not src/content/):

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const blog = defineCollection({
  // Load all .md and .mdx files from the blog directory
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string().min(10).max(100),
    description: z.string().max(160), // SEO-optimized length
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    author: z.string().default("Anonymous"),
    heroImage: z.string().optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };

What’s happening here:

  • glob() loads files matching the pattern from the specified base directory
  • z.coerce.date() converts date strings (like 2024-11-09) to JavaScript Date objects
  • .default() provides fallback values for optional fields
  • .max(160) on description enforces SEO-friendly meta description length

Step 2: Create Your Content Directory

Create the directory structure for your blog posts:

src/
├── content.config.ts          # Collection definitions
└── content/
    └── blog/
        ├── my-first-post.mdx
        ├── astro-tips.md
        └── tutorials/
            └── advanced-routing.mdx

The glob() loader with **/*.{md,mdx} pattern will find files in subdirectories too, so you can organize content however you like. MDX files let you embed interactive UI components directly in your content.

Step 3: Write Your First Content File

Create src/content/blog/my-first-post.mdx:

---
title: "Building My First Astro Site"
description: "A walkthrough of creating a blazing-fast static site with Astro"
pubDate: 2024-11-09
author: "Your Name"
heroImage: "/images/first-post.jpg"
tags: ["astro", "tutorial", "web-development"]
---

Welcome to my blog! This post was validated by Zod at build time.

## Why Astro?

Astro ships zero JavaScript by default, making it perfect for content sites...

If any required field is missing or has the wrong type, Astro shows a clear error during astro dev or astro build—not in production.

Step 4: Query Content in Your Pages

Create a blog index at src/pages/blog/index.astro:

---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";

// Fetch all blog posts, filtering out drafts in production
const posts = await getCollection("blog", ({ data }) => {
  return import.meta.env.PROD ? !data.draft : true;
});

// Sort by publication date (newest first)
const sortedPosts = posts.sort(
  (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
---

<Layout title="Blog">
  <h1>Latest Posts</h1>
  <ul role="list">
    {
      sortedPosts.map((post) => (
        <li>
          <a href={`/blog/${post.id}`}>
            <h2>{post.data.title}</h2>
            <p>{post.data.description}</p>
            <time datetime={post.data.pubDate.toISOString()}>
              {post.data.pubDate.toLocaleDateString("en-US", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
          </a>
        </li>
      ))
    }
  </ul>
</Layout>

Notice post.data is fully typed—your editor knows exactly what fields exist and their types. This type safety is similar to what you get with React Hooks when using TypeScript.

Step 5: Create Dynamic Post Pages

For individual posts, create src/pages/blog/[...slug].astro:

---
import { getCollection, render } from "astro:content";
import Layout from "../../layouts/Layout.astro";

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

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

<Layout title={post.data.title} description={post.data.description}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <p class="description">{post.data.description}</p>
      {
        post.data.heroImage && (
          <img
            src={post.data.heroImage}
            alt=""
            width="1200"
            height="630"
            loading="eager"
          />
        )
      }
      <div class="meta">
        <span>By {post.data.author}</span>
        <time datetime={post.data.pubDate.toISOString()}>
          {post.data.pubDate.toLocaleDateString()}
        </time>
      </div>
    </header>

    <Content />

    {
      post.data.tags.length > 0 && (
        <footer>
          <p>Tags: {post.data.tags.join(", ")}</p>
        </footer>
      )
    }
  </article>
</Layout>

The render() function returns a Content component for your Markdown/MDX and a headings array for building table of contents.

Understanding Loaders: glob() vs file()

Astro 5.0 provides two built-in loaders for local content:

The glob() Loader

Creates one entry per file. Best for content that renders as individual pages.

import { glob } from "astro/loaders";

const blog = defineCollection({
  loader: glob({
    pattern: "**/*.md", // Glob pattern for files
    base: "./src/content/blog", // Base directory
  }),
  schema: z.object({
    /* ... */
  }),
});

Use glob() for: Blog posts, documentation pages, portfolio projects, case studies.

The file() Loader

Creates multiple entries from a single file. Best for structured data.

import { file } from "astro/loaders";

const authors = defineCollection({
  loader: file("src/data/authors.json"),
  schema: z.object({
    id: z.string(),
    name: z.string(),
    bio: z.string(),
    avatar: z.string(),
    twitter: z.string().optional(),
  }),
});

Your authors.json would look like:

[
  {
    "id": "jane-doe",
    "name": "Jane Doe",
    "bio": "Frontend developer and technical writer",
    "avatar": "/images/authors/jane.jpg",
    "twitter": "@janedoe"
  },
  {
    "id": "john-smith",
    "name": "John Smith",
    "bio": "Full-stack engineer specializing in Astro",
    "avatar": "/images/authors/john.jpg"
  }
]

Use file() for: Author profiles, navigation menus, site configuration, product catalogs, team members.

Advanced Schema Patterns with Zod

Zod is the validation library powering content collections. Here are production-ready patterns. If you’re coming from a React and TypeScript background, our guide on TypeScript patterns for React developers covers many of the same type-safety concepts that Zod builds on.

Cross-Collection References

Link entries between collections using reference():

import { defineCollection, reference, z } from "astro:content";
import { glob, file } from "astro/loaders";

const authors = defineCollection({
  loader: file("src/data/authors.json"),
  schema: z.object({
    id: z.string(),
    name: z.string(),
    bio: z.string(),
  }),
});

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    author: reference("authors"), // Single reference
    relatedPosts: z.array(reference("blog")).optional(), // Array of references
  }),
});

export const collections = { authors, blog };

In your frontmatter, reference by ID:

---
title: "Understanding Astro Islands"
author: jane-doe
relatedPosts:
  - my-first-post
  - astro-tips
---

Query the referenced data in your component:

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

const post = await getEntry("blog", "understanding-astro-islands");
const author = await getEntry(post.data.author);
---

<p>Written by {author.data.name}</p>

Flexible Date Handling

Handle dates from different sources (CMS calendars vs manual entry):

schema: z.object({
  pubDate: z.union([
    z.coerce.date(), // Handles "2024-11-09" strings
    z.date(), // Handles Date objects from CMS
    z.string().transform((s) => new Date(s)), // Custom parsing
  ]),
});

Discriminated Unions for Content Types

Different content types with different required fields:

const content = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content" }),
  schema: z.discriminatedUnion("type", [
    z.object({
      type: z.literal("article"),
      title: z.string(),
      author: z.string(),
      readingTime: z.number(),
    }),
    z.object({
      type: z.literal("video"),
      title: z.string(),
      videoUrl: z.string().url(),
      duration: z.string(),
    }),
    z.object({
      type: z.literal("podcast"),
      title: z.string(),
      audioUrl: z.string().url(),
      guests: z.array(z.string()),
    }),
  ]),
});

Image Validation

Validate images with dimension requirements:

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

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      cover: image().refine((img) => img.width >= 1200, {
        message: "Cover image must be at least 1200px wide",
      }),
      thumbnail: image().optional(),
    }),
});

Loading Remote Content with Custom Loaders

The Content Layer API’s killer feature: load content from any source.

Basic Inline Loader

For simple API calls, use an inline async function:

const countries = defineCollection({
  loader: async () => {
    const response = await fetch("https://restcountries.com/v3.1/all");
    const data = await response.json();

    // Must return array with id property on each entry
    return data.map((country) => ({
      id: country.cca3,
      name: country.name.common,
      capital: country.capital?.[0],
      population: country.population,
      region: country.region,
    }));
  },
  schema: z.object({
    id: z.string(),
    name: z.string(),
    capital: z.string().optional(),
    population: z.number(),
    region: z.string(),
  }),
});

Advanced Loader with Caching

For production, build a loader object with incremental updates:

import type { Loader } from "astro/loaders";

export function cmsLoader(options: { apiUrl: string; token: string }): Loader {
  return {
    name: "cms-loader",
    load: async ({ store, meta, logger }) => {
      // Check for cached sync token
      const lastSync = meta.get("lastSync");

      const headers = {
        Authorization: `Bearer ${options.token}`,
        ...(lastSync && { "If-Modified-Since": lastSync }),
      };

      const response = await fetch(options.apiUrl, { headers });

      // Skip update if content hasn't changed
      if (response.status === 304) {
        logger.info("Content unchanged, using cache");
        return;
      }

      const data = await response.json();

      // Store sync token for next build
      meta.set("lastSync", response.headers.get("Last-Modified"));

      // Clear and repopulate store
      store.clear();
      for (const item of data.entries) {
        store.set({
          id: item.slug,
          data: item,
        });
      }

      logger.info(`Loaded ${data.entries.length} entries from CMS`);
    },
  };
}

Use it in your config:

import { cmsLoader } from "./loaders/cms";

const articles = defineCollection({
  loader: cmsLoader({
    apiUrl: "https://api.your-cms.com/articles",
    token: import.meta.env.CMS_TOKEN,
  }),
  schema: z.object({
    /* ... */
  }),
});

Community Loaders

Popular CMS platforms already have official loaders:

  • Storyblok: @storyblok/astro
  • Contentful: @contentful/astro
  • Sanity: @sanity/astro
  • Notion: notion-astro-loader
  • WordPress: astro-wp-loader
import { storyblokLoader } from "@storyblok/astro";

const stories = defineCollection({
  loader: storyblokLoader({
    accessToken: import.meta.env.STORYBLOK_TOKEN,
    version: "published",
  }),
});

Astro vs. Next.js for Content Sites

Choosing between Astro and Next.js for content-heavy sites? Here’s a direct comparison. For a broader look at how Astro stacks up against React-based frameworks for different use cases, see our React vs Next.js vs Astro comparison.

AspectAstroNext.js
Default JavaScriptZero JS shippedReact runtime required
Build Speed (1000 pages)~30 seconds~2-3 minutes
Content Type SafetyBuilt-in with ZodManual setup required
Learning CurveLower (HTML-first)Higher (React knowledge needed)
Interactive ComponentsIslands architectureFull hydration or RSC
Best ForBlogs, docs, marketingApps, dashboards, e-commerce
SSR SupportYes (on-demand)Yes (default)
Edge RuntimeYesYes
Image OptimizationBuilt-inBuilt-in
MDX SupportNativePlugin required

Choose Astro when:

  • Content is the primary focus (blogs, documentation, portfolios)
  • Performance is critical (Core Web Vitals matter for SEO)
  • You want minimal client-side JavaScript
  • Your team includes non-React developers

Choose Next.js when:

  • Building highly interactive applications
  • You need complex client-side state management
  • Your team is already React-focused
  • You’re building a full-stack application with API routes

For a deeper dive into React-based frameworks, see our React Server Components guide.

Performance Benchmarks

Real-world performance data comparing Astro 5.0 to alternatives:

MetricAstro 5.0Next.js 15Gatsby 5
Build Time (500 MD files)12s45s38s
Build Time (500 MDX files)18s52s44s
Memory Usage (build)180MB450MB520MB
Lighthouse Score (typical)10085-9590-98
JS Bundle (content page)0KB85KB+45KB+
Time to Interactiveunder 1s2-3s1.5-2s

Benchmarks from Astro team testing, March 2026. Results vary based on content complexity and plugins.

The Content Layer API specifically improved:

  • Markdown builds: Up to 5x faster than Astro 4.x
  • MDX builds: Up to 2x faster
  • Memory usage: 25-50% reduction
  • Incremental builds: Only changed files reprocessed

When to Use Content Collections (And When Not To)

Content collections aren’t always the right choice. Here’s a decision framework:

✅ Use Content Collections When:

ScenarioWhy Collections Work
Blog with any number of postsType safety, fast builds, zero runtime JS
Documentation sitePairs perfectly with Starlight, supports versioning
Portfolio/case studiesStructured data with consistent fields
E-commerce catalogWebhook-triggered rebuilds on product changes
Marketing pagesContent team edits, dev team deploys
Multi-language contentOne collection per locale, shared schema

For teams with design engineers, content collections provide the structured foundation that bridges design systems with content management.

❌ Avoid Content Collections When:

ScenarioBetter Alternative
Real-time data (stock prices, live scores)On-demand rendering (SSR)
Personalized content per userServer-side rendering with auth
Data changing every few minutesAPI calls at request time
User-generated contentDatabase + SSR
A/B testing variantsEdge middleware or Server Islands

🔄 Hybrid Approach: Server Islands

Combine static content collections with dynamic server-rendered sections. This pattern is similar to React Server Components, where you mix static and dynamic rendering:

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

const post = await getEntry("blog", Astro.params.slug);
const { Content } = await render(post);
---

<Layout>
  <!-- Static: rendered at build time from collection -->
  <article>
    <Content />
  </article>

  <!-- Dynamic: rendered on each request -->
  <aside server:defer>
    <RelatedPosts postId={post.id} />
    <Comments postId={post.id} />
  </aside>
</Layout>

This gives you the performance of static content with the flexibility of dynamic features.

Performance Optimization

Content collections are already optimized, but here’s how to maximize performance.

Filter Early, Not Late

Always filter in getCollection(), not after:

// ✅ Good: filters during fetch
const published = await getCollection("blog", ({ data }) => !data.draft);

// ❌ Avoid: fetches everything, then filters
const all = await getCollection("blog");
const published = all.filter((post) => !post.data.draft);

Use getEntry() for Single Items

When you only need one entry:

import { getEntry } from "astro:content";

// ✅ Efficient: fetches only what you need
const post = await getEntry("blog", "specific-post-id");

// ❌ Wasteful: fetches entire collection
const posts = await getCollection("blog");
const post = posts.find((p) => p.id === "specific-post-id");

Lazy Load Content Rendering

Only call render() when displaying full content:

// List page: just use metadata
const posts = await getCollection("blog");
posts.forEach((post) => {
  console.log(post.data.title); // ✅ No rendering needed
});

// Single page: render content
const { Content } = await render(post); // Only here

Leverage Build Caching

The Content Layer caches parsed content between builds. To maximize cache hits:

  1. Don’t modify content files unnecessarily
  2. Use generateDigest() in custom loaders for change detection
  3. Store sync tokens in meta for incremental API updates

Common Patterns and Recipes

Tag Pages with Dynamic Routes

Create src/pages/tags/[tag].astro:

---
import { getCollection } from "astro:content";
import Layout from "../../layouts/Layout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  // Extract unique tags
  const tags = [...new Set(posts.flatMap((post) => post.data.tags))];

  return tags.map((tag) => ({
    params: { tag },
    props: {
      tag,
      posts: posts.filter((post) => post.data.tags.includes(tag)),
    },
  }));
}

const { tag, posts } = Astro.props;
---

<Layout title={`Posts tagged "${tag}"`}>
  <h1>#{tag}</h1>
  <p>{posts.length} post{posts.length !== 1 ? "s" : ""}</p>
  <ul>
    {
      posts.map((post) => (
        <li>
          <a href={`/blog/${post.id}`}>{post.data.title}</a>
        </li>
      ))
    }
  </ul>
</Layout>

RSS Feed Generation

Create src/pages/rss.xml.ts:

import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";

export async function GET(context: APIContext) {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  const sortedPosts = posts.sort(
    (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
  );

  return rss({
    title: "My Blog",
    description: "Articles about web development and Astro",
    site: context.site!,
    items: sortedPosts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      description: post.data.description,
      link: `/blog/${post.id}/`,
      categories: post.data.tags,
    })),
    customData: `<language>en-us</language>`,
  });
}

Sitemap with Content Collections

Astro’s @astrojs/sitemap integration automatically includes collection pages. For custom control:

// astro.config.mjs
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://example.com",
  integrations: [
    sitemap({
      filter: (page) => !page.includes("/draft/"),
      changefreq: "weekly",
      priority: 0.7,
    }),
  ],
});

Search Index Generation

Create src/pages/search.json.ts for client-side search:

import { getCollection } from "astro:content";
import type { APIContext } from "astro";

export async function GET(context: APIContext) {
  const posts = await getCollection("blog", ({ data }) => !data.draft);

  const searchIndex = posts.map((post) => ({
    id: post.id,
    title: post.data.title,
    description: post.data.description,
    tags: post.data.tags,
    url: `/blog/${post.id}/`,
  }));

  return new Response(JSON.stringify(searchIndex), {
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": "public, max-age=3600",
    },
  });
}

Table of Contents from Headings

The render() function returns parsed headings:

---
import { getEntry, render } from "astro:content";

const post = await getEntry("blog", Astro.params.slug);
const { Content, headings } = await render(post);
---

<nav class="toc">
  <h2>Table of Contents</h2>
  <ul>
    {
      headings
        .filter((h) => h.depth <= 3)
        .map((heading) => (
          <li style={`margin-left: ${(heading.depth - 1) * 1}rem`}>
            <a href={`#${heading.slug}`}>{heading.text}</a>
          </li>
        ))
    }
  </ul>
</nav>

<Content />

Migrating from Legacy Content Collections

If you’re upgrading from Astro 2.x-4.x, here’s the migration path.

Config File Location

- src/content/config.ts
+ src/content.config.ts

Schema Definition Changes

// Before (Astro 2-4)
const blog = defineCollection({
-  type: "content",
  schema: z.object({ /* ... */ }),
});

// After (Astro 5+)
+ import { glob } from "astro/loaders";

const blog = defineCollection({
+  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({ /* ... */ }),
});

Data Collections Migration

// Before
const authors = defineCollection({
-  type: "data",
  schema: z.object({ /* ... */ }),
});

// After
+ import { file } from "astro/loaders";

const authors = defineCollection({
+  loader: file("src/content/authors/authors.json"),
  schema: z.object({ /* ... */ }),
});

Slug vs ID

In the legacy API, entries had a slug property. In Astro 5+, use id:

// Before
- <a href={`/blog/${post.slug}`}>

// After
+ <a href={`/blog/${post.id}`}>

Regenerate Types

After migrating, regenerate TypeScript types:

npx astro sync

Common Migration Mistakes

Avoid these pitfalls when upgrading to Astro 5.0:

1. Wrong config file location

❌ src/content/config.ts (old location)
✅ src/content.config.ts (new location)

2. Forgetting to update imports

// ❌ Old import
import { z, defineCollection } from "astro:content";

// ✅ New import (add loaders)
import { z, defineCollection } from "astro:content";
import { glob, file } from "astro/loaders";

3. Using slug instead of id

// ❌ Old API
<a href={`/blog/${post.slug}`}>
  // ✅ New API
  <a href={`/blog/${post.id}`}></a></a
>

4. Not running astro sync after schema changes Always run npx astro sync after modifying your schema. TypeScript errors about missing properties usually mean stale types.

5. Incorrect glob patterns

// ❌ Won't find files in subdirectories
loader: glob({ pattern: "*.md", base: "./src/content/blog" });

// ✅ Recursive pattern
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" });

6. Missing base path

// ❌ Relative to project root, might not find files
loader: glob({ pattern: "**/*.md", base: "content/blog" });

// ✅ Explicit path from project root
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" });

Troubleshooting Common Issues

”Collection not found” Error

Cause: Config file in wrong location or collection not exported.

Fix:

  1. Ensure config is at src/content.config.ts (not src/content/config.ts)
  2. Verify collection is exported: export const collections = { blog };
  3. Run npx astro sync

Schema Validation Errors

Cause: Frontmatter doesn’t match schema.

Fix:

  1. Check required fields are present
  2. Use YYYY-MM-DD format for dates
  3. Ensure arrays use proper YAML syntax: tags: ["a", "b"]
  4. Check for typos in field names

TypeScript Errors After Schema Changes

Cause: Generated types are stale.

Fix:

npx astro sync

Run this after any schema modification.

Empty Collection in Production

Cause: Draft filter excluding all posts, or glob pattern not matching files.

Fix:

  1. Check your filter function: ({ data }) => !data.draft
  2. Verify glob pattern matches your file extensions
  3. Ensure base path is correct relative to project root

Dev Server Hanging

Cause: Known issue with large collections in some Astro versions.

Fix:

  1. Update to latest Astro version
  2. Press s + Enter in terminal to manually refresh
  3. Check for circular references in your schema

Editor Integration and DX

JSON Schema for Data Files

Astro 4.13+ auto-generates JSON schemas in .astro/collections/. Configure VS Code to use them:

// .vscode/settings.json
{
  "json.schemas": [
    {
      "fileMatch": ["src/content/authors/*.json"],
      "url": "./.astro/collections/authors.schema.json"
    }
  ]
}

YAML Schema Support

Install the Red Hat YAML extension, then add to your YAML files:

# yaml-language-server: $schema=../../.astro/collections/blog.schema.json
---
title: "My Post"
pubDate: 2024-11-09
---

VS Code Snippets for Frontmatter

Create .vscode/astro.code-snippets:

{
  "Blog Post Frontmatter": {
    "prefix": "blogfront",
    "scope": "markdown,mdx",
    "body": [
      "---",
      "title: \"$1\"",
      "description: \"$2\"",
      "pubDate: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}",
      "author: \"${3:Your Name}\"",
      "tags: [\"$4\"]",
      "draft: ${5:false}",
      "---",
      "",
      "$0"
    ]
  }
}

SEO and Structured Data

Content collections pair perfectly with structured data for SEO.

JSON-LD for Blog Posts

Create a reusable component:

---
// src/components/BlogPostSchema.astro
interface Props {
  title: string;
  description: string;
  pubDate: Date;
  updatedDate?: Date;
  author: string;
  image?: string;
  url: string;
}

const { title, description, pubDate, updatedDate, author, image, url } =
  Astro.props;

const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  headline: title,
  description: description,
  datePublished: pubDate.toISOString(),
  dateModified: (updatedDate || pubDate).toISOString(),
  author: {
    "@type": "Person",
    name: author,
  },
  ...(image && { image }),
  mainEntityOfPage: {
    "@type": "WebPage",
    "@id": url,
  },
};
---

<script type="application/ld+json" set:html={JSON.stringify(schema)} />

Use in your blog post template:

---
import BlogPostSchema from "../components/BlogPostSchema.astro";
---

<head>
  <BlogPostSchema
    title={post.data.title}
    description={post.data.description}
    pubDate={post.data.pubDate}
    updatedDate={post.data.updatedDate}
    author={post.data.author}
    image={post.data.heroImage}
    url={Astro.url.href}
  />
</head>

FAQ Schema from Collection Data

If your frontmatter includes FAQs (like this article), generate FAQ schema:

---
const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: post.data.faqs?.map((faq) => ({
    "@type": "Question",
    name: faq.question,
    acceptedAnswer: {
      "@type": "Answer",
      text: faq.answer,
    },
  })),
};
---

{
  post.data.faqs && (
    <script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
  )
}

Quick Reference

Essential Commands

CommandPurpose
npx astro syncRegenerate TypeScript types after schema changes
npx astro devStart dev server with hot reload for content
npx astro buildBuild site, validates all content against schemas
npx astro checkType-check your project including content types

Key Files

FilePurpose
src/content.config.tsDefine collections, loaders, and schemas
.astro/types.d.tsAuto-generated types (don’t edit)
.astro/collections/*.schema.jsonJSON schemas for editor integration

API Functions

FunctionImportPurpose
getCollection()astro:contentFetch all entries in a collection
getEntry()astro:contentFetch single entry by ID
render()astro:contentRender Markdown/MDX to HTML
reference()astro:contentCreate cross-collection references
defineCollection()astro:contentDefine a collection in config
glob()astro/loadersLoad files from directory
file()astro/loadersLoad entries from single file

Key Takeaways

Before diving into the FAQ, here’s what you should remember:

  1. Astro 5.0 changed everything — The Content Layer API replaces the legacy type: "content" approach with loaders (glob(), file(), or custom). Config moved to src/content.config.ts.

  2. Type safety eliminates bugs — Zod schemas validate frontmatter at build time, not runtime. You’ll catch missing fields and wrong types before deployment.

  3. Performance gains are massive — Expect 5x faster Markdown builds, 2x faster MDX, and 25-50% less memory usage compared to Astro 2-4.

  4. Remote content is now first-class — Custom loaders can fetch from any CMS, API, or database with built-in caching and incremental updates.

  5. Know when NOT to use collections — Real-time data, personalized content, and frequently-changing data should use SSR or Server Islands instead.

  6. Always run npx astro sync — After any schema change, regenerate types to keep TypeScript happy.

Next Steps

You now have everything needed to build production-ready content collections. Here’s where to go next:

  1. Build a blog — Start with the basic setup, add tags and RSS
  2. Explore Starlight — Astro’s documentation framework built on content collections
  3. Integrate a CMS — Try Storyblok, Contentful, or Sanity loaders for team workflows
  4. Add search — Generate a search index and implement client-side search with Pagefind or Fuse.js

For complex content architectures, consider combining collections with design systems for consistent, maintainable component libraries. If you’re adding interactive elements to your content, check out our guide on building interactive UI components.

For teams managing state across content-heavy applications, our React state management comparison covers the best approaches for 2026.


Last updated: March 2026. Last tested with Astro 5.x. This guide covers Astro 5.0+ with the Content Layer API.

Frequently Asked Questions

What are Astro content collections?
Content collections are Astro's built-in system for loading, validating, and querying structured content from any source. Using the Content Layer API (Astro 5.0+), collections support local Markdown/MDX files via the glob() loader, JSON/YAML via the file() loader, or remote data from any API via custom loaders—all with TypeScript type safety and Zod schema validation.
What changed in Astro 5.0 content collections?
Astro 5.0 introduced the Content Layer API, replacing the legacy type: 'content' syntax with loaders (glob, file, or custom). The config file moved from src/content/config.ts to src/content.config.ts. Performance improved dramatically: Markdown builds are up to 5x faster, MDX 2x faster, with 25-50% less memory usage.
Do I need a CMS to use content collections?
No. Content collections work with local Markdown, MDX, JSON, and YAML files stored in your repository. This gives you version control, offline editing, and zero external dependencies. However, you can integrate with headless CMS platforms like Contentful, Sanity, or Storyblok using custom loaders.
Can I use MDX components in content collections?
Yes. Astro fully supports MDX in content collections. You can import and use React, Vue, Svelte, or Astro components directly in your content files, enabling interactive elements within your static content.
When should I NOT use content collections?
Avoid content collections for real-time data that changes frequently, personalized content per user, or data requiring instant updates without rebuilds. Use on-demand rendering (SSR) instead, or combine both approaches with Server Islands for static shells with dynamic sections.
How do content collections improve performance?
The Content Layer API caches parsed content between builds, only reprocessing changed files. Astro 5.0 delivers up to 5x faster Markdown builds, 2x faster MDX builds, and 25-50% reduced memory usage compared to the legacy API. Content is stored efficiently and generates zero client-side JavaScript by default.
What's the difference between glob() and file() loaders?
The glob() loader creates one entry per file from a directory of Markdown, MDX, JSON, or YAML files—ideal for blogs or docs. The file() loader creates multiple entries from a single file containing an array of objects—ideal for structured data like authors, products, or configuration.
How do I migrate from the legacy content collections API?
Move your config from src/content/config.ts to src/content.config.ts. Replace type: 'content' with loader: glob({ pattern: '**/*.md', base: './src/content/blog' }). Replace type: 'data' with loader: file('path/to/data.json'). Run npx astro sync to regenerate types.
How does Astro compare to Next.js for content-heavy sites?
Astro ships zero JavaScript by default, making it 2-3x faster for content sites. Next.js requires React hydration even for static content. Astro's content collections provide built-in type safety and validation that Next.js lacks. Choose Astro for blogs, docs, and marketing sites; Next.js for highly interactive apps.
Can I use content collections with Astro's View Transitions?
Yes. Content collections work seamlessly with Astro's View Transitions API. Your collection pages get smooth page transitions automatically when you add the ViewTransitions component to your layout. This creates app-like navigation without client-side JavaScript frameworks.
What is Astro Starlight and how does it use content collections?
Starlight is Astro's official documentation framework built entirely on content collections. It provides automatic navigation, search, i18n, and beautiful defaults. If you're building documentation, Starlight handles the content collection setup for you with optimized schemas and components.
How do I handle images in content collections?
Use the image() helper in your schema for local images with automatic optimization. For remote images, use z.string().url(). Astro 5.0+ supports image validation including dimension requirements. Images in the public folder work with simple string paths.