Skip to content
Astro as content 4 min read

Writing Custom Loaders

Astro’s built-in glob() and file() loaders cover local Markdown, MDX, JSON, and YAML, but real projects often source content from a headless CMS or a third-party API. The Content Layer API lets you write a custom loader — a small function that fetches remote data at build time and stores it in Astro’s data store, where it becomes a fully typed, queryable collection. This gives you the same getCollection() and getEntry() ergonomics for remote content that you get for local files, with zero JavaScript shipped to the browser by default.

How a loader works

A loader is an object with a name and a load() method (this is the “object loader” form, which is what you want for anything beyond a trivial inline fetch). Astro calls load() during the build, passing a context object. Your job is to fetch data, normalize each entry, and write it into the store keyed by a stable id. Astro persists this store between builds so unchanged data can be cached.

The most important pieces of the context are:

PropertyPurpose
storeKey/value data store. Use store.set(), store.clear(), store.keys().
loggerScoped logger so build output is labeled with your loader name.
parseDataValidates one entry against the collection schema and returns typed data.
generateDigestProduces a content hash used to detect changes and skip rebuilds.
metaA small persistent map for sync tokens, ETags, or “last fetched” markers.
renderMarkdownRenders a Markdown string so the entry supports render().

The load() function runs at build time only. For data that must be fresh on every request, fetch it in a page or endpoint instead, or pair the loader with on-demand rendering and revalidation.

A minimal API loader

Define the loader in a standalone file so it can be unit-tested and reused. Each entry must be stored with a unique id and a data object.

// src/loaders/articles-loader.ts
import type { Loader } from "astro/loaders";

export function articlesLoader(options: { endpoint: string }): Loader {
  return {
    name: "articles-loader",
    load: async ({ store, logger, parseData, generateDigest, meta }) => {
      logger.info(`Fetching articles from ${options.endpoint}`);

      const lastSync = meta.get("last-sync");
      const url = lastSync
        ? `${options.endpoint}?since=${lastSync}`
        : options.endpoint;

      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Article fetch failed: ${response.status}`);
      }

      const articles = (await response.json()) as Array<{
        slug: string;
        title: string;
        body: string;
        publishedAt: string;
      }>;

      for (const article of articles) {
        const data = await parseData({
          id: article.slug,
          data: article,
        });
        const digest = generateDigest(data);
        store.set({ id: article.slug, data, digest });
      }

      meta.set("last-sync", new Date().toISOString());
      logger.info(`Stored ${articles.length} articles`);
    },
  };
}

Wiring it into a collection

Pass the loader to defineCollection() exactly like the built-in loaders, and attach a Zod schema so every fetched entry is validated. If the remote shape drifts, the build fails loudly instead of shipping broken pages.

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { articlesLoader } from "./loaders/articles-loader";

const articles = defineCollection({
  loader: articlesLoader({
    endpoint: "https://cms.example.com/api/articles",
  }),
  schema: z.object({
    slug: z.string(),
    title: z.string(),
    body: z.string(),
    publishedAt: z.coerce.date(),
  }),
});

export const collections = { articles };

Now query it anywhere — in a page, a layout, or an endpoint — with the same typed API used for local collections.

---
// src/pages/blog/index.astro
import { getCollection } from "astro:content";

const articles = await getCollection("articles");
articles.sort((a, b) => +b.data.publishedAt - +a.data.publishedAt);
---

<ul>
  {articles.map((article) => (
    <li>
      <a href={`/blog/${article.id}`}>{article.data.title}</a>
      <time>{article.data.publishedAt.toLocaleDateString()}</time>
    </li>
  ))}
</ul>

Output:

[articles-loader] Fetching articles from https://cms.example.com/api/articles
[articles-loader] Stored 42 articles

Supporting render() with Markdown

If your CMS returns Markdown bodies and you want entry.render() to work, render the content inside the loader and attach rendered to the stored entry.

// inside load(), instead of a plain store.set()
const rendered = await renderMarkdown(article.body);
store.set({
  id: article.slug,
  data,
  digest,
  rendered,
});
---
import { getEntry, render } from "astro:content";

const article = await getEntry("articles", Astro.params.slug);
const { Content } = await render(article!);
---

<article><Content /></article>

Incremental syncing

Because store and meta persist across builds, you can avoid re-fetching unchanged data. Store an ETag, cursor, or timestamp in meta, send it as a conditional request, and only store.set() entries that actually changed. Compare the new digest against the stored one to skip writes. Call store.clear() only when you need a full refresh.

Best practices

  • Always validate entries with parseData() against a Zod schema so malformed CMS data fails the build instead of leaking into pages.
  • Use a stable, immutable id (a CMS UUID or slug) so URLs and the data store stay consistent across builds.
  • Persist a sync token or ETag in meta and use generateDigest() to enable incremental, cache-friendly builds.
  • Throw on non-OK responses and log with the provided logger so failures are visible in CI output.
  • Keep secrets in environment variables (read via import.meta.env or astro:env), never hard-coded in the loader.
  • Pre-render Markdown with renderMarkdown() at load time so render() works without per-request cost.
  • Publish loaders as object loaders so they accept options and stay reusable across projects.
Last updated June 14, 2026
Was this helpful?