Type something to search...
How to use the Content Layer API in Astro?

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:

  1. Loaders fetch content from files, APIs, databases or anything else.
  2. 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.json in my test project.
  3. Schemas validate each entry and generate TypeScript types.
  4. 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

ApproachBest forAccess to store and helpers
glob() / file()Local Markdown, MDX, JSON, YAML, TOMLHandled for you
Inline async functionSimple remote fetchesNo, Astro clears and refills the store
Loader objectRemote APIs, CMSs, custom file formats, incremental syncYes, 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 false to 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:

PropertyWhat it does
storeThe 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
metaA per-collection key-value store that persists between builds
loggerA logger that prefixes messages with your loader's name
configThe fully resolved Astro config, including root
watcherA file watcher, available only in dev
collectionThe collection's name
refreshContextDataExtra 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.env works in src/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-loader keyword.

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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper