The Content Layer API
Before Astro 5, content collections could only read Markdown, MDX, and local data files. The moment your content lived in a headless CMS or a remote API, you were back to hand-rolling fetch calls with no schema validation and no type safety. The Content Layer API removes that wall: it makes the source of content a pluggable concern called a loader, so files, CMSs, and arbitrary APIs all flow through the same defineCollection pipeline, the same Zod schemas, and the same getCollection query helpers. Content is loaded once at build time into a fast local data store, giving you uniform, type-safe access regardless of where the data actually came from.
Why the Content Layer matters
The key idea is separation of concerns. A collection no longer cares how entries arrive — it only declares a loader (the “where”) and a schema (the “shape”). This buys you three concrete wins:
- One mental model. Local Markdown, a Storyblok space, and a JSON REST endpoint are all queried with the same
getCollection/getEntryAPI. - Build-time validation everywhere. Remote data is validated against your Zod schema just like frontmatter, so a renamed CMS field fails the build instead of breaking production.
- Performance. Loaders populate an on-disk data store during the build, so pages render from a fast local cache rather than re-fetching on every request.
The Content Layer also improves cold-build performance dramatically for large sites — Astro caches loaded data and only re-runs loaders whose inputs changed.
How loaders fit together
A loader is just the value you pass to the loader key of defineCollection. Astro ships two built-in loaders from astro/loaders, and anyone can publish or write their own.
| Loader | Source | Typical use |
|---|---|---|
glob() | Files matching a pattern in a directory | Markdown/MDX blog posts, docs |
file() | A single local data file (JSON/YAML) | Authors list, navigation, config |
| Custom / third-party | Any API or CMS | Storyblok, Sanity, Hygraph, REST/GraphQL |
// 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(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
});
const authors = defineCollection({
loader: file("./src/data/authors.json"),
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
});
export const collections = { blog, authors };
Loading from a remote API
Because a loader is ultimately a function that returns entries, you can inline one to pull from any endpoint. An inline loader is an async function that receives a context and pushes entries into a store. This is the simplest way to wire up a CMS or REST API without authoring a reusable package.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
const products = defineCollection({
loader: async () => {
const res = await fetch("https://api.example.com/products");
const data = await res.json();
// The store keys each entry by `id`, so every record must have one.
return data.map((product: any) => ({
id: String(product.sku),
title: product.name,
price: product.price_cents / 100,
}));
},
schema: z.object({
id: z.string(),
title: z.string(),
price: z.number(),
}),
});
export const collections = { products };
Astro runs the loader at build time, validates every returned object against schema, and stores the result. If price ever comes back as null, the build fails with the offending entry id — no silent NaN shipping to users.
Querying loaded content
Querying is identical no matter which loader populated the collection. You import getCollection and getEntry from astro:content and use them in any component or page. Astro stays zero-JS by default: this all runs at build time and renders static HTML, with islands hydrated only where you add a client:* directive.
---
// src/pages/shop.astro
import { getCollection } from "astro:content";
import PriceTag from "../components/PriceTag.tsx";
const products = await getCollection("products");
const cheapest = products.sort((a, b) => a.data.price - b.data.price)[0];
---
<ul>
{products.map((p) => (
<li>{p.data.title} — ${p.data.price.toFixed(2)}</li>
))}
</ul>
<!-- Only this widget ships JS; the list above is static HTML. -->
<PriceTag client:visible price={cheapest.data.price} />
Output:
Astro Sticker Pack — $4.00
DevCraftly Mug — $12.50
Hoodie — $39.00
Building a reusable loader
For production CMS integrations you typically extract the logic into an object loader so it can be configured and shared. An object loader implements a name and a load method that uses the provided store, logger, and parseData helpers.
// src/loaders/cms.ts
import type { Loader } from "astro/loaders";
export function cmsLoader(opts: { space: string }): Loader {
return {
name: "cms-loader",
async load({ store, logger, parseData, meta }) {
logger.info(`Fetching space ${opts.space}`);
const res = await fetch(`https://cms.example.com/${opts.space}/entries`);
const entries = await res.json();
store.clear();
for (const entry of entries) {
const data = await parseData({ id: entry.id, data: entry });
store.set({ id: entry.id, data });
}
},
};
}
You then use it exactly like the built-ins: loader: cmsLoader({ space: "marketing" }). See Custom loaders for the full loader contract, including incremental updates via meta and digest-based change detection.
Best practices
- Always give every entry a stable, unique
id— the data store keys on it, and unstable ids break incremental rebuilds. - Define a strict Zod schema for remote sources; treat the CMS as untrusted input so renamed or missing fields fail loudly at build time.
- Prefer the built-in
globandfileloaders for local content; reach for custom loaders only when data is genuinely remote. - Extract repeated
fetchlogic into an object loader so it can be reused, configured, and unit-tested. - Keep transformation light inside loaders; do heavy formatting in components so cached data stays close to the source shape.
- Use
client:visibleorclient:idlefor any interactive island built from loaded content to preserve Astro’s zero-JS-by-default baseline.