
How to use the Content Layer API in Astro?
To use the Content Layer API in Astro, give every collection in src/content.config.ts a loader. For local files, use the built-in glob() or file() loaders from astro/loaders. For anything else, write a loader yourself: either an async function that returns an array of objects with an id, or a loader object with a name and a load() method that writes entries into Astro's data store. Astro runs every loader during content sync, validates the results against your schema, persists them between builds and exposes them through the usual getCollection(), getEntry() and render() functions.
The Content Layer arrived in Astro 5 and became the only way to define collections in Astro 6, when the legacy type: 'content' collections were removed. In this article you'll learn how the layer is structured, when to use each kind of loader, what the load() context gives you, how to render Markdown and track changes with digests and metadata, how to watch local files in development, and how schemas from loaders interact with collection schemas. The examples were built and type-checked with Astro 7.4, and the log output shown is from those runs.
How the Content Layer Works
The Content Layer sits between your content sources and your pages:
- Loaders fetch content from files, APIs, databases or anything else.
- The data store holds the loaded entries. It's a key-value store scoped per collection and persisted to disk, in
node_modules/.astro/data-store.jsonin my test project. - Schemas validate each entry and generate TypeScript types.
- Query functions (
getCollection(),getEntry(),getEntries(),render()) read from the store.
Because pages read from the store rather than from your sources, the source can be anything, and pages don't care. A collection loaded from Markdown files and one loaded from a REST API are queried in exactly the same way.
Loading happens during content sync, which runs at the start of astro dev, astro build and astro sync. In dev you can trigger it again by pressing s then Enter in the terminal.
Three Ways to Load Content
| Approach | Best for | Access to store and helpers |
|---|---|---|
glob() / file() | Local Markdown, MDX, JSON, YAML, TOML | Handled for you |
| Inline async function | Simple remote fetches | No, Astro clears and refills the store |
| Loader object | Remote APIs, CMSs, custom file formats, incremental sync | Yes, full LoaderContext |
Built-In Loaders
The built-in loaders cover local content. Both are imported from astro/loaders:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob, file } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: z.object({ title: z.string(), pubDate: z.coerce.date() }),
});
const authors = defineCollection({
loader: file("src/data/authors.json"),
schema: z.object({ name: z.string(), email: z.email() }),
});
export const collections = { blog, authors };
glob() makes one entry per matching file and generates IDs from file paths. file() makes many entries from one file whose objects each have an id. Both have options worth knowing:
- glob() generateId: return your own ID from the file path or data.
- glob() retainBody: set to
falseto drop the raw body from the store, which shrinks it considerably for big collections. - glob() deferRender: added in Astro 7.1. Renders Markdown when a page needs it instead of during sync, which keeps memory use bounded on very large collections at the cost of not caching rendered HTML.
- file() parser: a function that turns the file's text into entries, for formats like CSV or for picking one key out of a nested JSON document.
These are covered from the user's side in what content collections are in Astro. The rest of this article is about writing your own.
Inline Function Loaders
The quickest custom loader is an async function. It must return either an array of objects that each have a unique id, or an object whose keys are the IDs:
// src/content.config.ts (excerpt)
const users = defineCollection({
loader: async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const users: Array<{ id: number; name: string; email: string }> =
await response.json();
return users.map((user) => ({
id: String(user.id),
name: user.name,
email: user.email,
}));
},
schema: z.object({ name: z.string(), email: z.email() }),
});
Every time content syncs, Astro clears this collection's store and fills it with whatever the function returns. In a page, await getEntry("users", "1") returned the first user, and firstUser?.data.name rendered "Leanne Graham".
Function loaders are ideal for small, simple data sets. They have no logger, no Markdown rendering, no digests and no persistent metadata, so for anything more involved you'll want a loader object.
Writing a Loader Object
A loader object has a name, a load() method and, optionally, a schema. The usual shape is a function that takes options and returns the object, so users can configure it:
// src/loaders/changelog-loader.ts
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Loader, LoaderContext } from "astro/loaders";
import { z } from "astro/zod";
export function changelogLoader(fileName: string) {
async function sync({
config,
store,
logger,
parseData,
renderMarkdown,
generateDigest,
}: LoaderContext) {
const fileUrl = new URL(fileName, config.root);
const source = await readFile(fileUrl, "utf8");
// Split "## 1.2.0 (2026-10-01)" sections into separate entries
const sections = source.split(/^## /m).slice(1);
store.clear();
for (const section of sections) {
const [heading, ...lines] = section.split("\n");
const match = heading.match(/^(\S+)\s+\((\d{4}-\d{2}-\d{2})\)/);
if (!match) continue;
const [, version, date] = match;
const body = lines.join("\n").trim();
const data = await parseData({ id: version, data: { version, date } });
store.set({
id: version,
data,
body,
digest: generateDigest(body),
rendered: await renderMarkdown(body),
});
}
logger.info(`Loaded ${sections.length} releases`);
}
return {
name: "changelog-loader",
schema: z.object({
version: z.string(),
date: z.coerce.date(),
}),
load: async (context) => {
await sync(context);
const filePath = fileURLToPath(new URL(fileName, context.config.root));
context.watcher?.add(filePath);
context.watcher?.on("change", async (changed) => {
if (changed === filePath) {
context.logger.info("CHANGELOG.md changed, reloading");
await sync(context);
}
});
},
} satisfies Loader;
}
This loader turns a single CHANGELOG.md into one entry per release:
# Changelog
## 1.2.0 (2026-10-01)
- Added **dark mode**.
- Fixed the RSS feed date format.
## 1.1.0 (2026-08-12)
- Added tag pages.
Register it like any other loader:
// src/content.config.ts (excerpt)
import { changelogLoader } from "./loaders/changelog-loader";
const releases = defineCollection({
loader: changelogLoader("./CHANGELOG.md"),
});
Note satisfies Loader rather than a : Loader return type. The docs recommend it because it type-checks the object while keeping the specific types inferred, which matters for the schema types your collection ends up with.
Because this example uses Node built-ins, astro check reported "Cannot find name 'node:fs/promises'" until I installed @types/node as a dev dependency. If you see that error, run npm i -D @types/node.
The Loader Context
load() receives a LoaderContext object. These are the properties you'll use most:
| Property | What it does |
|---|---|
store | The collection's data store: set(), get(), has(), delete(), clear(), keys(), values(), entries() |
parseData() | Validates data against the collection's schema and returns the parsed result |
renderMarkdown() | Renders a Markdown string to HTML using Astro's Markdown pipeline |
generateDigest() | Creates a non-cryptographic hash of a string or object |
meta | A per-collection key-value store that persists between builds |
logger | A logger that prefixes messages with your loader's name |
config | The fully resolved Astro config, including root |
watcher | A file watcher, available only in dev |
collection | The collection's name |
refreshContextData | Extra data passed when an integration triggers a reload |
Storing Entries
store.set() takes a DataEntry with an id and data, plus optional body, filePath, digest and rendered. Always pass data through parseData() first. The store itself does no validation, and the docs are clear that it's the loader's job.
Set filePath (relative to the project root) when entries come from local files and use the image() schema helper. Without it, image fields are treated as public paths and not optimised.
Rendering Markdown
renderMarkdown() uses the same processor as your .md files. In Astro 7 that's Sätteri by default, so any Sätteri plugins you've configured apply to loader content too. Assign the result to rendered, and render(entry) will return a working <Content /> component:
---
// src/pages/changelog.astro
import { getCollection, render } from "astro:content";
const releases = (await getCollection("releases")).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
---
<h1>Changelog</h1>
{releases.map(async (release) => {
const { Content } = await render(release);
return (
<section>
<h2>{release.data.version}</h2>
<time datetime={release.data.date.toISOString()}>
{release.data.date.toLocaleDateString("en-GB")}
</time>
<Content />
</section>
);
})}
The build produced real HTML from the Markdown list:
<section><h2>1.2.0</h2><time datetime="2026-10-01T00:00:00.000Z">01/10/2026</time><ul>
<li>Added <strong>dark mode</strong>.</li>
<li>Fixed the RSS feed date format.</li>
</ul>
</section>
If your source returns HTML rather than Markdown, set rendered: { html } directly.
Digests and Skipping Unchanged Entries
When you pass a digest, store.set() compares it with the existing entry and returns false without updating if they match. This lets a loader avoid unnecessary work and lets Astro know which entries really changed. It only helps if you don't call store.clear() first, because then there's nothing to compare against. I cover that incremental pattern in how to load content from a remote API with an Astro loader.
Persistent Metadata
meta is a small key-value store for things like sync tokens or "last fetched" timestamps. Values survive between builds:
const lastSynced = meta.get("lastSynced");
// ...fetch only what changed since lastSynced...
meta.set("lastSynced", new Date().toISOString());
There's one catch I saw in testing. Changing the content config clears the whole store, metadata included, so after editing src/content.config.ts the next sync starts from scratch:
[content] Content config changed
[content] Clearing content store
[json-api-loader] Fetching https://jsonplaceholder.typicode.com/posts?_limit=3 (last synced: never)
Design loaders so a missing meta value always means "do a full load".
Watching Files in Development
watcher is only defined in dev, hence the optional chaining. For files outside src/, add them to the watcher yourself with watcher.add(), then listen for change events. With the changelog loader running under astro dev, adding a release to CHANGELOG.md reloaded the collection without a restart:
[changelog-loader] Loaded 2 releases
[changelog-loader] CHANGELOG.md changed, reloading
[changelog-loader] Loaded 3 releases
Schemas: Loader vs Collection
A loader can provide a default schema through its schema property. If the collection doesn't define its own schema, the loader's one is used for validation and types. If the collection does define one, the collection's schema wins. That lets users of your loader extend or reshape the data:
const releases = defineCollection({
loader: changelogLoader("./CHANGELOG.md"),
// Overrides the loader's schema
schema: z.object({
version: z.string(),
date: z.coerce.date(),
major: z.boolean().default(false),
}),
});
If the schema has to be generated dynamically, for example by asking a CMS for its content model, use createSchema(). It's an async function that returns { schema, types }, where types is TypeScript source that must export an Entry type. Astro 6 removed the older approach of making schema itself a function; a loader that still does that logs a warning and is treated as having no schema.
Live Loaders Are Different
Everything above describes build-time loaders. Live collections, defined in src/live.config.ts with defineLiveCollection(), use a different loader shape: instead of load(), a live loader implements loadCollection() and loadEntry(), and returns data (or an error) directly on each request with no data store. They need an adapter for on-demand rendering and can't render MDX or optimise images. If you need content that's fresh on every request, look at live loaders. If you can rebuild when content changes, build-time loaders are faster and simpler.
Tips for Writing Good Loaders
- Fail loudly on bad responses: throwing inside
load()stops the build with your message, which is better than publishing an empty collection. A loader of mine that hit a 404 stopped the build with "Request to https://jsonplaceholder.typicode.com/nope failed with 404" and a non-zero exit code. - Use the logger:
logger.info()messages are prefixed with your loader's name, which makes build logs readable. - Keep IDs stable: IDs end up in URLs. Use the source's permanent identifier, not an array index.
- Validate before storing: always call
parseData(). - Read secrets from the environment:
import.meta.envworks insrc/content.config.ts, so pass tokens into your loader as options rather than hard-coding them. See how to use environment variables in Astro. - Publish reusable loaders: a loader is just a function, so you can ship it as an npm package with the
astro-loaderkeyword.
FAQ: The Content Layer API
It's the system underneath them. Content collections are what you define and query, and the Content Layer is the loader, data store and schema machinery that loads and caches their data. Since Astro 6, every collection uses it.
Use a function for simple fetches where you just return an array of entries. Use a loader object when you need validation with parseData(), Markdown rendering, digests, persistent metadata, logging or file watching.
In a data store that persists between builds. In an Astro 7.4 project I tested, it was written to node_modules/.astro/data-store.json. Changing the content config clears it.
Call renderMarkdown() from the loader context with the Markdown string and assign the result to the entry's rendered property. Pages can then use render(entry) and the Content component as usual.
The collection's schema. A loader's schema is a default used only when the collection doesn't define its own, which lets users extend or transform the loader's data.
Yes. Use the watcher from the loader context to listen for file changes and re-run your loading logic. The watcher only exists in development, so access it with optional chaining.
Conclusion
The Content Layer API turns any data source into a content collection. Use glob() and file() for local content, an inline async function for simple remote data, and a loader object when you need control. Inside load(), the context gives you everything required to do it properly: parseData() for validation, renderMarkdown() for HTML, generateDigest() and meta for incremental updates, and watcher for live reloading in development.
Whatever the source, the result is the same for your pages: typed entries in a persistent store, queried with getCollection() and rendered with render(). Start with the simplest loader that works, move to a loader object when you hit its limits, and keep IDs stable and validation strict so your content stays trustworthy as it grows.


