Fix: Astro Content Collections Not Working — Content Layer, Loaders, Schema, and References
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" 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).
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/.mdxundersrc/content/<collection>/. Slugs were derived from filenames; the entry API wasentry.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
getEntryBySlugimprovements 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 newdefineCollection({ loader, schema })shape and theglob/fileloaders 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.tsis no longer auto-discovered; you move it tosrc/content.config.ts. Theentry.render()method is replaced by therender(entry)function imported fromastro: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 syncrunning automatically beforeastro devfor 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.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. - Legacy collections in a 5.x project still want the old file. If you set
legacy: { collections: true }inastro.config.mjsto ease migration, Astro readssrc/content/config.tsagain — 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 runastro 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.setand the incremental loader shape, every dev reload refetches everything. Move to the full loader-object form and key offlast-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.
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.