Fix: Astro Content Collections Not Working — Content Layer, Loaders, Schema, and References
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" missingOr 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.ts→src/content.config.ts(orastro.config.mjs’scollectionsfield). The new location is outside thesrc/content/directory itself. - Loaders replace built-in directory scanning. Old collections auto-loaded
.md/.mdxfromsrc/content/<name>/. New collections use explicit loaders (glob,file, or custom). reference()returns a reference object. To follow it, you callgetEntry(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.tsPro 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
patterninglob(...)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:
getCollectionreturns empty array. Either no files match the loader pattern, or the schema validation rejected all entries. Runastro checkfor 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.mdwithout extension). Configureslugin 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/mdxis in your integrations:
// astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
export default defineConfig({ integrations: [mdx()] });- Headings in
headingsare 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
getCollectionon 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 syncto regenerate.astro/types.d.ts. Astro generates types fromcontent.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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Astro Server Islands Not Working — server:defer, Fallback, Cookies, Caching, and Hydration
How to fix Astro 5 server islands — server:defer directive ignored, fallback slot missing, cookies/headers in deferred component, output config mismatch, dynamic island fetch URL, and caching the static shell.
Fix: React Router 7 Not Working — Framework Mode, Loaders, Type Safety, and Remix Migration
How to fix React Router v7 errors — framework mode vs library mode setup, loader/action data type narrowing, route module exports missing, single-fetch revalidation, hydration mismatch, and Remix v2 migration paths.
Fix: Astro DB Not Working — Tables Not Found, Queries Failing, or Seed Data Missing
How to fix Astro DB issues — schema definition, seed data, queries with drizzle, local development, remote database sync, and Astro Studio integration.
Fix: Astro Actions Not Working — Form Submission Failing, Validation Errors Missing, or Return Type Wrong
How to fix Astro Actions issues — action definition, Zod validation, form handling, progressive enhancement, error handling, file uploads, and calling actions from client scripts.