Astro's Content Layer, explained through its loaders
Content Layer turned content collections from a folder reader into a pluggable data layer. What a loader really is, how to write one, and why every CMS integration for Astro now looks the same.
Astro’s Content Layer is the single most important thing that happened to CMS integration in this ecosystem, and it is under-explained. Most articles show you a config snippet and move on. This one goes underneath, because once you understand what a loader is, every CMS integration for Astro stops being magic and starts being obvious.
Before: collections were a folder
In early Astro, a content collection was a directory. You put markdown in src/content/blog/, declared a Zod schema, and getCollection('blog') gave you typed entries. It worked well and it had one hard limitation: content had to be files on disk, in your project.
If your content lived in a CMS, you were on your own. Every integration invented its own approach: a top-level await fetch() in a page, a custom script that wrote markdown into src/content/ before the build, a bespoke helper library per vendor. None of them got the two things collections gave you for free: typed access through getCollection(), and a build-time cache that did not refetch on every page.
After: a collection is whatever a loader says it is
Content Layer inverts it. A collection no longer is a folder; a collection has a loader, and the loader’s job is to put entries into a store. Where those entries come from is entirely the loader’s business.
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
export const collections = {
// Files on disk: the old behaviour, now just one loader among others.
docs: defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/docs' }) }),
};
The glob loader is not a special case. It is an ordinary loader that happens to read the filesystem. Swap it for a loader that reads an API, a database, a CSV, or a snapshot on a CDN, and everything downstream is unchanged: getCollection(), typing, render(), all identical.
That is the whole idea, and it is why every CMS integration for Astro published after Content Layer looks broadly the same.
What a loader actually is
At its simplest, a loader is an object with a name and a load function:
const myLoader = {
name: 'my-loader',
async load({ store, parseData, logger }) {
const items = await fetchFromSomewhere();
store.clear();
for (const item of items) {
const data = await parseData({ id: item.slug, data: item });
store.set({ id: item.slug, data });
}
},
};
Three things are worth dwelling on.
store is the collection. Whatever you put in it is what getCollection() returns. Nothing else is involved.
parseData runs your schema. This is where validation happens. If the collection declares a Zod schema, parseData enforces it and throws a useful error pointing at the offending entry. Skipping it means shipping unvalidated data into typed code, which defeats the point.
logger writes into Astro’s build output. Use it. A loader that fails silently during a client’s deployment is a bad afternoon.
There is also a digest mechanism for incremental work: give store.set() a digest of the entry, and on the next run Astro can skip entries whose digest has not changed. On a small site this is noise. On a site with a few thousand entries it is the difference between a build you wait for and a build you tolerate.
The schema question
A loader can declare its own schema instead of making you write one:
export const collections = {
posts: defineCollection({ loader: menestrelLoader({ collection: 'posts' }) }),
};
No Zod schema in sight, and post.data.title is still typed. The loader knows the shape, because the CMS knows the shape.
This is a genuinely better arrangement than the alternative, where you declare the model in the CMS and then declare it again in Zod, and the two drift apart. Whichever CMS you use, prefer the loader that derives the schema over the one that asks you to restate it.
Where content comes from at build time, and why it matters
Content Layer makes the source pluggable, and that raises a question worth asking of any CMS integration: what exactly does my build talk to?
There are three answers, and they have very different failure modes.
The CMS’s live API. Simple, and it means your production build depends on a third-party service being up and fast at the moment you deploy. If the API is slow, your build is slow. If it is down, your deploy fails. On a Friday afternoon, that is somebody’s problem.
A local cache or export. Fast and robust, but somebody has to keep it fresh, and “somebody” is a script you now maintain.
An immutable snapshot on a CDN. The content of a given publication is frozen into a file with a stable URL and pushed to a CDN. The build fetches that file. It cannot change under you mid-build, it is served from an edge cache, and the CMS being unavailable is irrelevant.
That third one is how Menestrel works, and it is a deliberate architectural choice rather than an implementation detail. The build reads MENESTREL_CONTENT_URL, which points at the published snapshot. Nothing else is needed: no token in your deployment environment, no dependency on our API. We verify it by switching the API off and confirming a client’s build still succeeds.
Writing your own loader
If you have a data source nobody has integrated, writing a loader is a couple of hours’ work, and the result is a first-class Astro citizen. The checklist:
- Fetch and normalise. Turn your source into a flat list of entries with stable ids.
- Run
parseDataso schema violations fail loudly at build time. - Set a digest if entries can be numerous, so incremental builds work.
- Clear before a full refresh, or handle removals explicitly, otherwise deleted entries linger in the store between builds. This is the bug everybody writes once.
- Log something. Number of entries loaded, source URL, elapsed time.
- Fail loudly. A loader that swallows a network error and returns zero entries will publish an empty site, and the deploy will be green.
That last point deserves emphasis. The worst possible loader behaviour is a silent empty result: your build passes, your deploy succeeds, and your client’s site now has no services page. Prefer throwing.
What this changed for CMS vendors
Before Content Layer, integrating a CMS with Astro meant shipping a helper library and a page of documentation about Astro.glob workarounds. After it, the contract is small and identical for everyone: give Astro a loader, put entries in the store, declare the schema.
This is good for you. It means switching CMS is now a change in content.config.ts and a content migration, rather than rewriting every page component. It also means you should be sceptical of any Astro CMS integration in 2026 that is not a loader, because it is either old or reinventing something that already exists.
For the practical side, our loader reference documents the resolution order and the environment variables, and the first site guide puts a working project together in about ten minutes.
Two failure modes worth knowing before they bite
Stale entries after a delete. The store persists between builds in development. If your loader adds entries without clearing, and something was deleted at the source, the deleted entry keeps existing locally and disappears only in CI, where the cache is cold. That produces the worst kind of bug: works on my machine, broken in production, with no error anywhere. Either clear the store on a full refresh, or track ids and remove what the source no longer returns.
Schema drift between environments. If the loader derives the schema from a remote source, and that source changes shape while a colleague is on an older branch, their build fails with a validation error about a field they have never heard of. This is correct behaviour, and it is confusing the first time. The fix is process rather than code: treat a content-model change the way you treat a database migration, and land it before the code that depends on it.
Both of these are the price of content being remote. They are not arguments against Content Layer; they are the things to put in your project’s README.
What this means when choosing a CMS
Because the contract is now small and public, the interesting differences between Astro CMS integrations are no longer about the integration at all. Every serious one is a loader, and they all give you typed collections.
What still differs, and what you should actually evaluate:
- Where the build reads from, and therefore what happens when the vendor has an outage during your deploy.
- Whether the loader derives the schema or makes you restate it in Zod, with the drift that invites.
- What the editor experience is, which the loader tells you nothing about.
- What happens after publish, which is entirely outside Content Layer’s scope and is where most tools stop caring.
That last point is worth sitting with. Content Layer standardised getting content out. It said nothing about the loop that starts when a non-technical person clicks a button and ends when their page is genuinely being served. Every CMS still solves that differently, or does not solve it at all.
The limits, stated plainly
Content Layer is a build-time mechanism. It gives you typed content at build, nothing at runtime. If you need content that changes per request, this is not the tool, and you want server rendering with a live data fetch instead.
It also does not solve the editing side at all. A loader gets content out of somewhere; how it got in, and who is allowed to put it there, is a completely separate problem. That is the problem a CMS solves, and the comparisons go through how the main ones handle it.