Astro Content Collections: Complete 2026 Guide for Astro 6

Master Astro 6 content collections with build-time and live collections, Zod 4 schemas, remote loaders, and a clean migration path from the legacy API.

Inzimam Ul Haq
Inzimam Ul Haq
· 18 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. When I migrated our enterprise documentation site from legacy APIs to Astro 6, the sheer volume of undocumented frontmatter edge-cases nearly halted our deployment until we fully embraced Content Collections.

Astro content collections are Astro’s typed content system for Markdown, MDX, JSON, YAML, and remote data. The big shift now is not just “use the Content Layer API.” In Astro 6, you need to understand the split between build content collections for static content (which utilize Incremental builds to speed up subsequent deployments) and live content collections for request-time data.

As of March, 2026, Astro 6.0.2 is the latest stable release. That matters because Astro 6 removes legacy collections by default, deprecates z from astro:content in favor of astro/zod, and adds stable live content collections for API-backed data.

This guide covers the current setup for Astro 6, while still showing the migration path from Astro 2-5 projects.

TL;DR - 7 Things You Need to Know

  1. Astro 6 is current - Latest stable is astro@6.0.2, and the content docs now separate build and live collections.
  2. Build content collections still power blogs and docs - Use src/content.config.ts with defineCollection(), glob(), and file().
  3. Live content collections are now stable - Use src/live.config.ts with defineLiveCollection() when data must stay fresh without rebuilds.
  4. Use astro/zod for schemas - Astro 6 deprecates importing z from astro:content.
  5. Legacy collections are off by default - Migrate from type: "content" and type: "data", or temporarily enable legacy.collectionsBackwardsCompat.
  6. Runtime requirements changed too - Astro 6 upgrades to Node.js 22.12+ and Vite 7.
  7. What does npx astro sync do? The npx astro sync command generates TypeScript definitions for your Astro content collections. You should run it whenever you modify your ZodSchema inference in src/content.config.ts or add new content files to ensure your IDE provides accurate autocomplete and type-checking.

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

What is the Astro Content Layer API?

The Astro Content Layer API is a unified system introduced in Astro 5 and refined in Astro 6 that allows developers to define, validate, and query content collections. It uses Zod schemas to enforce frontmatter shape at build time and supports both local files via the glob loader and remote data sources. For the official documentation, see the Astro Content Collections Guide and the Astro 6 upgrade guide.

  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. Live content collections are stable in Astro 6 — Remote data can now stay fresh at request time via src/live.config.ts
  4. Schema imports changed — New Astro 6 examples should import z from astro/zod
  5. Legacy collections are deprecated out of the box — Old src/content/config.ts projects need migration or a temporary compatibility flag
  6. Content Layer performance remains the foundation — The original build-time gains from Astro 5 still apply to modern build collections
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-5? The config file location changed from src/content/config.ts to src/content.config.ts, and Astro 6 disables legacy collections unless you explicitly opt into compatibility. See the migration section below.

Build Collections vs Live Collections

This is the biggest source of confusion in current Astro docs, so keep the split clear:

ModeConfig FileBest ForMain APIsCurrent Caveats
Build content collectionssrc/content.config.tsBlogs, docs, MDX, local files, static datadefineCollection(), getCollection(), getEntry()Data updates require a rebuild
Live content collectionssrc/live.config.tsAPI data, read-heavy remote contentdefineLiveCollection(), getLiveCollection()No MDX rendering or image() helper support yet

Note on Querying: It is crucial to understand getCollection vs getEntry vs getLiveCollection. Use getCollection to fetch all build-time items (like an index page), getEntry to fetch a single build-time item efficiently without fetching the whole collection, and getLiveCollection specifically for request-time, API-backed data. For strict typing of single items across your components, use the TypeScript generic import type { CollectionEntry } from "astro:content"; and type your props as CollectionEntry<"blog">.

If your content is authored in Markdown or MDX, stay with build collections. Reach for live collections when the source of truth is remote and freshness matters more than build-time caching.

Setting Up Your First Content Collection

Let’s build a blog collection from scratch using the current Astro 6 build-collection 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 } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

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’s build collections still ship with two built-in local loaders:

The glob() Loader

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

import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

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 { defineCollection } from "astro:content";
import { file } from "astro/loaders";
import { z } from "astro/zod";

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.

Cross-Collection References

Link entries between collections using reference():

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

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 } from "astro:content";
import { z } from "astro/zod";

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 at Build Time with Custom Loaders

For data you can fetch during a build, the Content Layer API still lets you load remote 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",
  }),
});

Live Content Collections in Astro 6

Astro 6 makes live content collections stable. Use them when data changes often enough that rebuilds become the wrong abstraction.

The mental model is simple:

  • Build collections are for authored content you own.
  • Live collections are for remote content you read.
  • SSR is still the right tool when the output depends on auth, per-user state, or writes.

Live collections live in src/live.config.ts and are queried with getLiveCollection() or getLiveEntry(). They are a better fit than build-time custom loaders for dashboards, inventory, pricing, status feeds, or other data that must stay fresh on request.

At the time of writing, they still have trade-offs: no MDX rendering, no image() helper support, and a hard dependency on the remote source being available when the page renders.

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
Content PipelineOptimized for static and hybrid contentBroader app-oriented routing model
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 and Scale

The defensible performance story is this:

  • Astro’s official Content Layer rollout reported up to 5x faster Markdown builds than the legacy API.
  • The same rollout reported up to 2x faster MDX builds.
  • Memory usage dropped by 25% to 50% versus legacy collections.
  • Current Astro docs position build collections as suitable for tens of thousands of content entries, because parsed data is cached between builds.

Those are strong baseline improvements, but they are not a substitute for benchmarking your own repo. MDX plugins, image transforms, remote loaders, CI hardware, and your route count will dominate the actual result.

When to Use Build Content Collections (And When Not To)

Build content collections still are not the right choice for every data source. Here’s the current decision framework:

✅ Use Build 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 Build Content Collections When:

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

🔄 Hybrid Approach: Build Collections + Dynamic Data

Combine static build collections with dynamic sections when the page shell is stable but some data must stay fresh. This pattern is similar to React Server Components, where you mix static and dynamic rendering:

---
import { getEntry, render } 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. In Astro 6, live collections give you another option when that dynamic section is read-only remote data rather than fully personalized output.

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-5.x, here’s the migration path. Astro 5 let many legacy projects keep running. Astro 6 is less forgiving: the old API is disabled by default.

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+ / Astro 6 current)
+ import { glob } from "astro/loaders";
+ import { z } from "astro/zod";

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";
+ import { z } from "astro/zod";

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

Slug vs ID

In the legacy API, entries often relied on slug. In modern build collections, use id:

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

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

Temporary Compatibility Flag in Astro 6

If you need a short migration window after upgrading Astro itself, Astro 6 provides a temporary compatibility escape hatch:

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

export default defineConfig({
  legacy: {
    collectionsBackwardsCompat: true,
  },
});

Use this only to unblock the upgrade. Do not treat it as the long-term target state.

Regenerate Types

After migrating, regenerate TypeScript types:

npx astro sync

Common Migration Mistakes

Avoid these pitfalls when upgrading to Astro 6:

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 { defineCollection, z } from "astro:content";

// ✅ Current Astro 6 import style
import { defineCollection } from "astro:content";
import { glob, file } from "astro/loaders";
import { z } from "astro/zod";

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. Forgetting Astro 6 disables legacy collections by default

If you upgrade the framework first and the project suddenly cannot find collections, you are probably still on src/content/config.ts or type: "content". Migrate immediately, or temporarily set legacy.collectionsBackwardsCompat: true.

6. 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" });

7. 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, collection not exported, or Astro 6 is still running with legacy collections disabled.

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. If you upgraded Astro before migrating collections, temporarily enable legacy.collectionsBackwardsCompat: true
  4. 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.

z Deprecation Warning in Astro 6

Cause: Your project or copied snippets still import z from astro:content.

Fix:

import { defineCollection } from "astro:content";
import { z } from "astro/zod";

Keep defineCollection() in astro:content, but move schema imports to astro/zod.

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

Live Collection Request Failures

Cause: The remote API is down, rate-limited, or timing out at request time.

Fix:

  1. Add defensive fallbacks in the page UI
  2. Cache upstream responses where possible
  3. Prefer build collections when the data does not need request-time freshness

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 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
src/live.config.tsDefine live collections for request-time data
.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 build collection
getEntry()astro:contentFetch one build-collection entry by ID
getLiveCollection()astro:contentFetch all entries in a live collection
getLiveEntry()astro:contentFetch one live-collection entry
render()astro:contentRender Markdown/MDX from build collections
reference()astro:contentCreate cross-collection references
defineCollection()astro:contentDefine a build collection
defineLiveCollection()astro:contentDefine a live collection
zastro/zodDefine schemas in Astro 6
glob()astro/loadersLoad files from a directory
file()astro/loadersLoad entries from a single file

Key Takeaways

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

  1. Astro 6 changes the framing, not the fundamentals — Build collections still power blogs, docs, and MDX, but live collections are now stable for request-time remote data.

  2. Type safety still eliminates bugs — Zod schemas validate frontmatter at build time, not runtime. In Astro 6, import z from astro/zod.

  3. The Content Layer gains are real — Astro officially reported up to 5x faster Markdown builds, up to 2x faster MDX builds, and 25-50% lower memory versus legacy collections.

  4. Remote content now has two paths — Use build-time custom loaders when rebuilds are acceptable; use live collections when freshness matters.

  5. Do not force every data problem into build collections — Personalized output still belongs in SSR, and fast-changing remote data usually belongs in live collections.

  6. Migration is stricter on Astro 6 — Legacy collections are disabled by default, though legacy.collectionsBackwardsCompat can buy you a short migration window.

  7. Always run npx astro sync — After any schema change, regenerate types to keep TypeScript and editor tooling in sync.

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 11, 2026. Verified against Astro 6.0.2 stable and the Astro 6 docs.

Frequently Asked Questions

What are Astro content collections?
Content collections are Astro's built-in system for loading, validating, and querying structured content with TypeScript type safety. In Astro 6, you now choose between build content collections in src/content.config.ts for Markdown, MDX, JSON, and YAML resolved at build time, and live content collections in src/live.config.ts for request-time data from APIs or databases.
What changed in Astro 6 for content collections?
Astro 6 keeps the Content Layer API from Astro 5, but adds stable live content collections, removes the legacy collections API by default, and deprecates importing z from astro:content in favor of astro/zod. It also upgrades the toolchain to Node.js 22.12+ and Vite 7, so version bumps affect both your content config and runtime environment.
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 build content collections?
Avoid build content collections for personalized content, user-generated content, or data that must stay fresh without a rebuild. In Astro 6, use live content collections for frequently changing read-heavy data, or SSR when the response depends on auth, per-user state, or write operations.
How do content collections improve performance?
Build content collections cache parsed data between builds and only reprocess changed entries. Astro's official Content Layer rollout reported up to 5x faster Markdown builds, up to 2x faster MDX builds, and 25-50% lower memory usage than the legacy API. For content pages, Astro still ships zero client-side JavaScript by default unless you opt into hydration.
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' }) and type: 'data' with loader: file('path/to/data.json'). In Astro 6, legacy collections are disabled by default, so either migrate fully or temporarily enable legacy.collectionsBackwardsCompat: true while you update the project. Finish by running npx astro sync.
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?
For build content collections, use the image() helper in your schema for local images with automatic optimization, and use z.string().url() for remote image URLs. In Astro 6, live content collections currently do not support image() or MDX rendering, so keep rich media content in build collections or handle remote assets separately.