Astro Content Layer: Writing a Custom Loader

A custom Astro content loader is an object with a name and an async load() function that writes entries into a data store. Pass it to defineCollection({ loader }) and content from your CMS behaves like local Markdown: validated against a schema, typed, cached between builds, and queried with getCollection().

The alternative is fetching in getStaticPaths(), which works right up until you have two routes that need the same data, a schema you want enforced, or a build that hammers the CMS API once per page. The loader exists so you only solve that once.

What does a content loader have to provide?

An object loader implements the Loader interface from astro/loaders: a name used in logs and for targeted refreshes, an async load(context), and optionally a schema (or a createSchema() function if you want to derive it from an API introspection call at build time).

Everything useful lives on the context object Astro hands load(). The Content Loader API reference lists the full set; five of them matter on day one.

store is the DataStore for the collection, with get, set, keys, values, entries, has, delete and clear. parseData({ id, data }) validates against the collection schema and returns the parsed object. renderMarkdown(content) runs Astro’s Markdown pipeline and returns a RenderedContent with html and metadata. generateDigest(data) produces a non-cryptographic hash of an object or string. meta is a key-value store scoped to the collection, persisted between builds and readable only inside the loader.

An entry passed to store.set() needs an id and data, and can carry body (the raw source), digest, filePath and rendered. The id is what getStaticPaths() will turn into a URL, so make it the slug your CMS already owns rather than a numeric database key.

How do I write a custom loader for a headless CMS?

Here is the shape in full, for a CMS that returns a JSON array of articles.

// src/loaders/cms.ts
import type { Loader, LoaderContext } from 'astro/loaders';

interface Article {
  slug: string;
  title: string;
  body: string;
  updatedAt: string;
}

export function cmsLoader(options: { endpoint: string }): Loader {
  return {
    name: 'cms-articles',

    async load({ store, logger, parseData, renderMarkdown, generateDigest }: LoaderContext) {
      const response = await fetch(options.endpoint);
      if (!response.ok) {
        throw new Error(`CMS responded ${response.status} ${response.statusText}`);
      }

      const articles: Article[] = await response.json();
      const seen = new Set<string>();

      for (const article of articles) {
        seen.add(article.slug);

        const digest = generateDigest(article);
        if (store.get(article.slug)?.digest === digest) continue;

        const data = await parseData({
          id: article.slug,
          data: { title: article.title, updatedAt: article.updatedAt },
        });

        store.set({
          id: article.slug,
          data,
          body: article.body,
          digest,
          rendered: await renderMarkdown(article.body),
        });
      }

      for (const id of store.keys()) {
        if (!seen.has(id)) store.delete(id);
      }

      logger.info(`Loaded ${seen.size} articles from the CMS.`);
    },
  };
}

Wire it up in src/content.config.ts. Note the z import: since Astro 6 you import Zod from astro/zod, and astro:schema plus z from astro:content are deprecated. The v6 upgrade guide is worth a read if you are coming from an older project, because it also removed legacy src/content/ collections outright and replaced a loader’s schema-as-a-function with createSchema().

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { cmsLoader } from './loaders/cms';

const articles = defineCollection({
  loader: cmsLoader({ endpoint: 'https://cms.example.com/api/articles' }),
  schema: z.object({
    title: z.string(),
    updatedAt: z.coerce.date(),
  }),
});

export const collections = { articles };

From there the page is the same code you would write for a folder of Markdown files.

---
// src/pages/articles/[id].astro
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const articles = await getCollection('articles');
  return articles.map((entry) => ({ params: { id: entry.id }, props: { entry } }));
}

const { entry } = Astro.props;
const { Content } = await render(entry);
---

<h1>{entry.data.title}</h1>
<Content />

Why does the digest matter?

Because the store survives between builds. Astro persists it under .astro/, so a rebuild starts warm and every entry whose content has not changed can be skipped.

store.set() already knows this: the docs say it “returns false when the digest property determines that an entry has not changed and should not be updated”. That saves the write, but not the work. Rendering Markdown is the expensive part of the loop, so check the digest yourself before you call renderMarkdown(), exactly as the sample above does. Comparing against store.get(id)?.digest costs nothing and skips the pipeline entirely.

Digest the raw payload from the CMS rather than the transformed object, so an editor changing a field you do not map cannot quietly go unnoticed later when you start mapping it.

The meta store takes this further. Save a sync token or a timestamp after each run and ask the API only for what changed:

const since = meta.get('lastSync');
const url = since ? `${options.endpoint}?updatedAt_gt=${since}` : options.endpoint;
// ...after a successful pass
meta.set('lastSync', new Date().toISOString());

One catch, and it bites people. Incremental sync and the deletion sweep at the bottom of the loop are incompatible. If the API only returns what changed, everything else is missing from the response, and the sweep will delete your entire collection. Either fetch everything and sweep, or fetch incrementally and get a deletions feed from the CMS to drive store.delete().

What happens when the CMS is down mid-build?

Nothing good, unless you decide in advance. A fetch failure throws out of load() and fails the build, which is the right outcome on cold CI where the store is empty and the alternative is deploying a site with no blog.

On a warm store you have a choice. Our own loader logs a warning and keeps the previously cached entries rather than dropping them:

if (store.keys().length > 0) {
  logger.warn(`Could not reach the CMS (${(error as Error).message}). Reusing cached entries.`);
  return;
}
throw error;

That distinction only helps if the store is actually warm, which on most CI runners means caching the .astro directory between jobs.

How do I refresh content without restarting the dev server?

In astro dev, press s then Enter to re-run the loaders. For a CMS webhook, integrations get refreshContent in the astro:server:setup hook, typed as (options: { loaders?: Array<string>; context?: Record<string, any>; }) => Promise<void> and available since Astro 5.0.

// integrations/cms-webhook.ts
import type { AstroIntegration } from 'astro';

export function cmsWebhook(): AstroIntegration {
  return {
    name: 'cms-webhook',
    hooks: {
      'astro:server:setup': ({ server, refreshContent }) => {
        server.middlewares.use('/_cms-refresh', async (req, res) => {
          await refreshContent({ loaders: ['cms-articles'], context: { source: 'webhook' } });
          res.statusCode = 200;
          res.end('ok');
        });
      },
    },
  };
}

Whatever you pass as context arrives in the loader as refreshContextData, so a loader can read the webhook body and refresh one entry instead of the lot. This is a dev-server facility. In production the equivalent is your host’s build hook, or live collections.

When should I use live collections instead?

When the data is too volatile to bake in. Live collections fetch at request time, are configured in a separate src/live.config.ts with defineLiveCollection, need an adapter and on-demand rendering, and are read with getLiveCollection() and getLiveEntry(). A live loader implements loadCollection() and loadEntry() instead of load(), and returns errors rather than throwing:

---
import { getLiveCollection } from 'astro:content';

const { entries, error } = await getLiveCollection('products');
if (error) {
  return Astro.rewrite('/500');
}
---

The trade-offs are real. No MDX rendering, no image optimisation, and nothing is written to the content layer store, so every request pays for the fetch unless you cache it. Route caching went stable in [email protected] and Astro.cache.set() accepts the cacheHint (tags and lastModified) a live loader returns, which is the cleanest way to put a TTL and tag-based invalidation on those routes. Astro 7, released on 22 June 2026, also shipped CDN cache providers for Netlify, Vercel and Cloudflare, still experimental as of August 2026.

Stock levels, prices, search results and anything personalised belong in a live collection, or in a server island if the rest of the page can stay static. Blog posts, docs, case studies and marketing pages belong in a build-time loader, because a page that changes twice a month should not pay an API call per visit.

Start with the build-time loader. It is about forty lines, it gives you types and schema validation for free, and it turns a CMS outage into a warning rather than a broken deploy. Reach for a live collection only for the fields that genuinely cannot wait for a rebuild, and leave everything else in the store. If you are picking the CMS itself rather than the loader, that is a different question, and one we work through in our CMS development work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch