Skip to content

Fix: Astro Content Collections Not Working — Content Layer, Loaders, Schema, and References

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Astro content collections errors — content/config.ts moved to content.config.ts, glob loader patterns, schema validation, references between collections, live reload on add/remove, and remote loaders.

The Error

You upgrade to Astro 5 and content collections stop loading:

[astro] Content collections must be defined in `src/content.config.ts`.
The file `src/content/config.ts` is no longer supported.

Or your schema validation fails for all entries:

[content] Could not parse frontmatter for "post-1.md":
Required field "pubDate" missing

Or references don’t resolve:

const post = await getEntry("blog", "post-1");
console.log(post.data.author);
// Reference object, not the actual author data.

Or new files in src/content/blog/ don’t show up until you restart the dev server:

# Create src/content/blog/new-post.md
# astro dev keeps serving old content.

Why This Happens

Astro 5 introduced the Content Layer API, a redesign of collections:

  • Config moved. src/content/config.tssrc/content.config.ts (or astro.config.mjs’s collections field). The new location is outside the src/content/ directory itself.
  • Loaders replace built-in directory scanning. Old collections auto-loaded .md/.mdx from src/content/<name>/. New collections use explicit loaders (glob, file, or custom).
  • reference() returns a reference object. To follow it, you call getEntry(reference) or use the auto-population utilities.
  • Schemas are still Zod. That’s unchanged. But invalid frontmatter now fails the build (not just dev warnings).

The deeper reason these errors look so confusing is that Astro 5’s Content Layer is a from-the-ground-up rewrite that shares a name and most of the public API with the old collections, but has very different internals. The old design loaded everything in src/content/ at dev startup; the new design defers loading to per-collection loaders that can be incremental, remote, or cache-aware. That model unlocks features the old one couldn’t support (remote CMS data, very large collections, custom transformers) — but it means subtle behavior differences. An empty getCollection() result that used to mean “no files” now might mean “loader hasn’t run” or “loader silently rejected everything against the schema.”

The other thing that trips people up is migration timing. Astro 5 ships the Content Layer as the default, but the legacy collections API is still supported behind a legacy.collections flag until Astro 6. Teams that upgraded incrementally often end up with a half-migrated codebase: some collections through the legacy API, some through Content Layer, and helper utilities that branch differently depending on which one they hit. The error messages are vague because Astro tries to support both paths during the transition.

Version History: How Content Collections Evolved

Each Astro major changed what collections can do — and what shape your config needs:

  • Astro 2.0 (January 2023) introduced content collections as a first-class feature. The setup was src/content/config.ts, Zod schemas, and automatic loading of every .md / .mdx under src/content/<collection>/. Slugs were derived from filenames; the entry API was entry.render() (a method on the entry object).
  • Astro 3.0 (August 2023) added native image optimization with the image() schema helper, allowing frontmatter image paths to be validated and pre-processed at build time.
  • Astro 4.0 (December 2023) focused on DX. View Transitions matured, the dev overlay became standard, and collections gained getEntryBySlug improvements plus better TypeScript types. Server Islands appeared as an experimental preview alongside this release.
  • Astro 4.14 (mid-2024) shipped the Content Layer API as experimental under experimental.contentLayer. The new defineCollection({ loader, schema }) shape and the glob / file loaders landed here. Teams running 4.14+ could trial it before committing.
  • Astro 5.0 (November 2024) made Content Layer the default and is the source of most of the “config moved” errors people hit today. src/content/config.ts is no longer auto-discovered; you move it to src/content.config.ts. The entry.render() method is replaced by the render(entry) function imported from astro:content. Server Islands also stabilized in 5.0.
  • Astro 5.1–5.5 (early 2025) polished the Content Layer: better cache hit rates on incremental builds, improved error messages, and astro sync running automatically before astro dev for type generation.

If you’re reading code or tutorials, the entry.render() pattern dates the article to pre-5.0. The render(entry) function dates it to 5.0+. The defineCollection({ type: "content", schema }) form (no loader) is the legacy API; with a loader: field, it’s Content Layer.

Fix 1: Move Config to src/content.config.ts

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

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
});

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

export const collections = { blog, authors };

Key points:

  • loader: is required now — there’s no implicit directory scan.
  • glob(...) for many files matching a pattern; file(...) for one file with an array of records.
  • Schemas live the same way as before (Zod z.object).

Move the old file:

git mv src/content/config.ts src/content.config.ts

Pro Tip: The new location (src/content.config.ts, no folder) makes it discoverable in IDE file trees. Tools that index src/content/ for content (search, indexing scripts) no longer accidentally pick up the config file.

Fix 2: Use glob Loader Correctly

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

pattern accepts gitignore-style globs. base is the root for the pattern.

For nested directories:

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

// Files:
// src/content/docs/intro.md → id: "intro"
// src/content/docs/guides/install.md → id: "guides/install"

The collection’s entry id is the path relative to base, minus the extension.

For excluding files:

loader: glob({
  pattern: ["**/*.md", "!**/draft-*"],  // Exclude drafts
  base: "./src/content/blog",
});

For frontmatter-based draft filtering (better than path-based):

schema: z.object({
  draft: z.boolean().default(false),
  // ...
}),
---
const posts = (await getCollection("blog")).filter((p) => !p.data.draft);
---

Common Mistake: Forgetting to add the .mdx extension in the pattern. Without {md,mdx}, only .md files are loaded.

Fix 3: file Loader for JSON / YAML

For data files like author lists, taxonomies, single JSON arrays:

import { file } from "astro/loaders";

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

authors.json:

[
  { "id": "alice", "name": "Alice" },
  { "id": "bob", "name": "Bob" }
]

The id field in each record becomes the collection entry’s ID. For YAML, use a parser:

import yaml from "js-yaml";
import { file } from "astro/loaders";

const authors = defineCollection({
  loader: file("./src/data/authors.yaml", { parser: (text) => yaml.load(text) }),
  schema: z.object({
    id: z.string(),
    name: z.string(),
  }),
});

parser is optional; defaults to JSON.parse.

Fix 4: References Between Collections

For author field referencing the authors collection:

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

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

In a blog post’s frontmatter:

---
title: "My Post"
author: alice
relatedPosts:
  - post-1
  - post-2
---

In your template:

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

const post = await getEntry("blog", "my-post");
const author = await getEntry(post.data.author);  // Resolves the reference
const related = await getEntries(post.data.relatedPosts ?? []);
---

<article>
  <h1>{post.data.title}</h1>
  <p>By {author.data.name}</p>
  <ul>
    {related.map((p) => <li>{p.data.title}</li>)}
  </ul>
</article>

getEntry(reference) resolves a single reference; getEntries([...refs]) for arrays.

Common Mistake: Trying to access post.data.author.name directly. References are objects ({ collection, id }), not the resolved data. Always getEntry the reference first.

Fix 5: Render Markdown / MDX Content

The Content Layer changed how rendering works:

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

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

<Content />

For headings list (auto-extracted):

const { Content, headings } = await render(post);

<aside>
  <ul>
    {headings.map((h) => <li><a href={`#${h.slug}`}>{h.text}</a></li>)}
  </ul>
</aside>
<Content />

Old post.render() (method on the entry) is deprecated. Use render(post) (function from astro:content).

Pro Tip: Cache render(post) results if you call it multiple times for the same post (rare, but for sitemap generation or RSS where you might iterate).

Fix 6: Live Reload on Add/Remove

Astro 5’s dev server watches files in the patterns you configured. If new files aren’t picked up:

  • Check the pattern in glob(...) actually matches the new file (case-sensitive).
  • Check the file’s extension is in pattern.
  • Some editors create temp files (.foo.swp, ~foo). These may interfere with the watcher; ignore them.

If you need to force a reload:

# Restart astro dev to fully refresh the content graph.

For VS Code: disable “files.autoSaveFocusChange” — partial saves can confuse the loader’s diff.

Common Mistake: Editing content.config.ts itself and expecting the dev server to update. Config changes need a dev server restart.

Fix 7: Custom Loaders for Remote Data

For data fetched from an API (CMS, database, headless service):

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

const posts = defineCollection({
  loader: async () => {
    const response = await fetch("https://api.example.com/posts");
    const data = await response.json();
    return data.map((p) => ({
      id: p.slug,           // Required: every entry needs an `id`
      title: p.title,
      body: p.body_markdown,
      pubDate: p.published_at,
    }));
  },
  schema: z.object({
    title: z.string(),
    body: z.string(),
    pubDate: z.coerce.date(),
  }),
});

export const collections = { posts };

The loader function returns an array of objects, each with an id field. Astro caches the result; re-runs on file change or astro build.

For full loader objects with cache:

const posts = defineCollection({
  loader: {
    name: "remote-posts",
    load: async ({ store, logger, parseData, meta }) => {
      const since = meta.get("last-fetched");
      const response = await fetch(`https://api.example.com/posts?since=${since ?? ""}`);
      const data = await response.json();
      
      for (const p of data) {
        const item = await parseData({ id: p.slug, data: p });
        store.set({ id: p.slug, data: item });
      }
      
      meta.set("last-fetched", new Date().toISOString());
    },
  },
  schema: z.object({ ... }),
});

store.set / store.get / meta.get / meta.set give you incremental loading — useful for large CMS exports.

Fix 8: Image Handling in Collections

For frontmatter images that should be optimized:

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

image() is the Astro-provided helper that validates and tags the image for processing.

In frontmatter:

---
title: "My Post"
cover: ./images/cover.jpg   # Path relative to the .md file
---

In your template:

---
import { Image } from "astro:assets";
const post = await getEntry("blog", "my-post");
---

<Image src={post.data.cover} alt={post.data.title} />

Astro processes the image at build time — resizing, format conversion, lazy loading attributes.

Common Mistake: Using a string path instead of image(). The string is passed through unchanged; <Image> won’t process it. Use image() in the schema for full optimization.

Still Not Working?

A few less-obvious failures:

  • getCollection returns empty array. Either no files match the loader pattern, or the schema validation rejected all entries. Run astro check for detailed errors.
  • Build fails on a single bad frontmatter. Schema validation is strict by default. Either fix the frontmatter or make the field optional/default.
  • Old entry IDs after switching from legacy collections. Legacy IDs preserved slugs (my-post); new ones can use full paths (my-post.md without extension). Configure slug in your schema or migrate URLs with redirects.
  • reference("authors") errors at runtime. The referenced collection doesn’t exist or has a different name. Names must match exactly.
  • MDX components don’t render. Make sure @astrojs/mdx is in your integrations:
// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
export default defineConfig({ integrations: [mdx()] });
  • Headings in headings are wrong. Astro extracts headings from rendered HTML. If your MDX has components that emit headings, those may or may not be detected. Use plain Markdown for predictable extraction.
  • Slow getCollection on large sets. All entries are loaded into memory on first call. For thousands of entries, paginate at the page level or pre-build static indices.
  • TypeScript errors after restructuring. Run astro sync to regenerate .astro/types.d.ts. Astro generates types from content.config.ts — without sync, types are stale.
  • Legacy collections in a 5.x project still want the old file. If you set legacy: { collections: true } in astro.config.mjs to ease migration, Astro reads src/content/config.ts again — but only for collections you haven’t moved to the Content Layer shape. Mixing the two means two source-of-truth files; resolve by migrating everything off legacy.
  • Content Layer cache stuck after upgrading Astro. Astro 5.x stores per-collection cache state in node_modules/.astro/. Delete that directory plus .astro/ at the project root, then run astro sync && astro dev. Stale caches can preserve old IDs or old data shapes after schema changes.
  • Remote loaders hammer the API on every dev reload. Without using meta.get/meta.set and the incremental loader shape, every dev reload refetches everything. Move to the full loader-object form and key off last-fetched (or a CMS revision token) to avoid the storm.

For related Astro and content handling issues, see Astro DB not working, Astro actions not working, Astro Server Islands not working, and MDX not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles