Skip to content

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

FixDevs ·

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).

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.

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