Type something to search...
How to use a headless CMS with Astro?

How to use a headless CMS with Astro?

To use a headless CMS with Astro, fetch your content from the CMS's API and turn it into a content collection, either with a custom loader in src/content.config.ts or with a loader or integration the CMS provides. Astro then treats CMS entries like local Markdown: you query them with getCollection(), generate pages with getStaticPaths() and render body content with render(). Store the API token in a server-only environment variable, and set up a webhook so the site rebuilds when editors publish. If you need content to appear without a rebuild, such as for previews, use a live collection with an adapter so entries are fetched on each request.

A headless CMS handles writing, editing and asset management, and leaves presentation to you. That's a natural fit for Astro, which is designed to turn content into fast pages. In this article you'll learn how the pieces fit together, how to keep credentials safe with astro:env, how to write a build-time loader for a REST CMS and render its Markdown, how to build list and detail pages, how to add live previews, how to rebuild on publish, and how official SDKs and integrations compare with writing your own loader.

How a Headless CMS Fits into Astro

There are two moments when Astro can fetch CMS content, and choosing between them is the first decision:

ApproachWhen content is fetchedNeeds an adapterContent updates appear
Build-time collectionDuring astro build (and astro dev)NoAfter the next build
Live collectionOn every requestYesImmediately
Direct fetch() in a pageAt build time, or per request for on-demand pagesOnly for on-demandDepends on rendering mode

For most marketing sites and blogs, build-time collections are the right default. Pages are static HTML, the CMS isn't called by visitors, and an outage at the CMS doesn't take your site down. Live collections suit content that changes constantly or must be previewed before publishing. The post on static vs on-demand rendering covers the trade-offs in more detail.

Astro's documentation has guides for dozens of CMSs, including Contentful, Sanity, Storyblok, Strapi, Prismic, Hygraph, DatoCMS, Directus and WordPress. Some offer an Astro integration or loader package, others a JavaScript SDK, and every one of them has an HTTP API you can call with fetch().

Step 1: Store Credentials Safely

CMS API tokens must never reach the browser. Astro's astro:env module lets you declare environment variables with a type, a context and an access level:

// astro.config.mjs
import { defineConfig, envField } from 'astro/config';

export default defineConfig({
  env: {
    schema: {
      CMS_URL: envField.string({ context: 'server', access: 'public' }),
      CMS_TOKEN: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});

Then set the values locally in a .env file, and in your host's dashboard for production:

# .env
CMS_URL=https://cms.example.com
CMS_TOKEN=your-read-only-token

A few practical rules:

  • Use a read-only token: a delivery or read-only API key limits the damage if it leaks.
  • Keep .env out of Git: commit a .env.example with empty values instead.
  • Secrets are read at runtime on servers: in an on-demand build, secret variables aren't baked into the bundle, so the server needs them in its environment too. When I started the built Node server without CMS_TOKEN, every on-demand page failed with EnvInvalidVariables: ... CMS_TOKEN is missing.

The guide to type-safe environment variables explains the other options, such as defaults and optional fields.

Step 2: Write a Build-Time Loader

A loader tells Astro how to fetch entries and put them in its content store. Suppose the CMS exposes GET /api/posts, returns JSON like { "data": [ ... ] } and stores the body as Markdown. Here's a loader for it:

// src/loaders/cms-loader.ts
import type { Loader } from 'astro/loaders';
import { z } from 'astro/zod';

type CmsPost = {
  slug: string;
  title: string;
  excerpt: string;
  publishedAt: string;
  body: string;
};

export function cmsLoader(options: { url: string; token: string }) {
  return {
    name: 'cms-loader',
    load: async ({ store, parseData, renderMarkdown, generateDigest, logger }) => {
      const res = await fetch(`${options.url}/api/posts`, {
        headers: { Authorization: `Bearer ${options.token}` },
      });
      if (!res.ok) {
        throw new Error(`CMS request failed: ${res.status} ${res.statusText}`);
      }
      const { data } = (await res.json()) as { data: CmsPost[] };

      store.clear();
      for (const post of data) {
        const parsed = await parseData({ id: post.slug, data: post });
        store.set({
          id: post.slug,
          data: parsed,
          digest: generateDigest(post),
          rendered: await renderMarkdown(post.body),
        });
      }
      logger.info(`Loaded ${data.length} posts from the CMS`);
    },
    schema: z.object({
      title: z.string(),
      excerpt: z.string(),
      publishedAt: z.coerce.date(),
    }),
  } satisfies Loader;
}

What each part does:

  • store.clear() and store.set(): replace the collection's entries with the fresh set, so deleted CMS entries disappear too.
  • parseData(): validates each entry against the schema and applies transforms, such as turning the date string into a Date.
  • renderMarkdown(): renders the body with the same Markdown pipeline as local files, which makes render() and <Content /> work later.
  • generateDigest(): a hash of the entry so Astro can tell when it has changed.
  • logger: messages labelled with the loader name.
  • satisfies Loader: since Astro 6, collection types are inferred from the loader, and satisfies keeps that inference working while still checking the shape.

Two details caught me out while testing. First, use a type alias rather than an interface for the CMS data: parseData() expects a Record<string, unknown>, and an interface without an index signature fails astro check. Second, throwing on a failed request is deliberate. A build that fails loudly is better than one that silently deploys an empty blog.

If your CMS returns HTML instead of Markdown, skip renderMarkdown() and pass rendered: { html: post.html } instead. If you don't need any helpers, a loader can also be a plain async function that returns an array of objects with an id.

Register the Collection

Use the loader in your content config, reading the credentials from astro:env/server:

// src/content.config.ts
import { defineCollection } from 'astro:content';
import { CMS_URL, CMS_TOKEN } from 'astro:env/server';
import { cmsLoader } from './loaders/cms-loader';

const posts = defineCollection({
  loader: cmsLoader({ url: CMS_URL, token: CMS_TOKEN }),
});

export const collections = { posts };

There's no schema here because the loader provides one. If you define a schema on the collection, it replaces the loader's, which is useful when you want to transform fields differently. Running npx astro sync fetches the content and generates types:

11:11:08 [content] Syncing content
11:11:08 [cms-loader] Loaded 2 posts from the CMS
11:11:08 [content] Synced content
11:11:08 [types] Generated 459ms

Step 3: Build the Pages

From here, CMS content behaves exactly like local content. A list page sorts the entries:

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

const posts = (await getCollection('posts')).sort(
  (a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf()
);
---
<html lang="en">
  <head><meta charset="utf-8" /><title>Blog</title></head>
  <body>
    <h1>Blog</h1>
    <ul>
      {posts.map((post) => (
        <li>
          <a href={`/blog/${post.id}/`}>{post.data.title}</a>
          <p>{post.data.excerpt}</p>
        </li>
      ))}
    </ul>
  </body>
</html>

A dynamic route creates one page per entry and renders its body:

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

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

const { post } = Astro.props;
const { Content } = await render(post);
---
<html lang="en">
  <head><meta charset="utf-8" /><title>{post.data.title}</title></head>
  <body>
    <article>
      <h1>{post.data.title}</h1>
      <time datetime={post.data.publishedAt.toISOString()}>
        {post.data.publishedAt.toLocaleDateString('en-GB')}
      </time>
      <Content />
    </article>
  </body>
</html>

Note the current APIs: entries have an id (there's no slug property any more), and rendering uses render(post) imported from astro:content rather than a method on the entry. Building against a test API with two posts produced:

 generating static routes
11:11:01   ├─ /blog/hello-astro/index.html (+15ms)
11:11:01   ├─ /blog/second-post/index.html (+2ms)
11:11:01   ├─ /blog/index.html (+4ms)
11:11:01 ✓ Completed in 37ms.

And the Markdown body from the CMS rendered to HTML, with a heading ID generated automatically:

<article><h1>Hello Astro</h1><time datetime="2026-10-01T09:00:00.000Z">01/10/2026</time><h2 id="welcome">Welcome</h2>
<p>This post came from the <strong>CMS</strong>.</p>
</article>

For more on querying, see sorting and filtering collections and the deeper guide to remote content loaders.

Step 4: Rebuild When Content Is Published

With build-time collections, published changes appear after the next build. Automate that with a webhook:

  1. Create a build hook in your hosting platform. Netlify calls these build hooks and Vercel calls them deploy hooks. Either way you get a unique URL that starts a new deployment when it receives a POST request.
  2. Add a webhook in your CMS pointing at that URL, triggered on publish, unpublish and delete events.
  3. Test it by publishing a small change and watching a new deployment start.

Keep the hook URL private, since anyone who has it can trigger builds. If your CMS sends many events in quick succession, check whether it can batch or debounce them to avoid a queue of redundant builds.

Step 5: Add Live Previews with a Live Collection

Editors usually want to see drafts before publishing. A live collection fetches entries at request time, so you can serve preview pages without rebuilding. It needs an adapter, because the page renders on demand:

// astro.config.mjs
import { defineConfig, envField } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  env: {
    schema: {
      CMS_URL: envField.string({ context: 'server', access: 'public' }),
      CMS_TOKEN: envField.string({ context: 'server', access: 'secret' }),
    },
  },
});

A live loader has two methods instead of load(): loadCollection() for lists and loadEntry() for a single entry. Errors are returned, not thrown:

// src/loaders/cms-live-loader.ts
import type { LiveLoader } from 'astro/loaders';

type CmsPost = {
  slug: string;
  title: string;
  excerpt: string;
  publishedAt: string;
  body: string;
};

export function cmsLiveLoader(options: { url: string; token: string }): LiveLoader<CmsPost> {
  const headers = { Authorization: `Bearer ${options.token}` };

  return {
    name: 'cms-live-loader',
    loadCollection: async () => {
      try {
        const res = await fetch(`${options.url}/api/posts`, { headers });
        const { data } = (await res.json()) as { data: CmsPost[] };
        return { entries: data.map((post) => ({ id: post.slug, data: post })) };
      } catch (error) {
        return { error: new Error('Failed to load posts', { cause: error }) };
      }
    },
    loadEntry: async ({ filter }) => {
      try {
        const res = await fetch(`${options.url}/api/posts/${filter.id}`, { headers });
        if (res.status === 404) return undefined;
        const { data } = (await res.json()) as { data: CmsPost };
        return { id: data.slug, data };
      } catch (error) {
        return { error: new Error('Failed to load post', { cause: error }) };
      }
    },
  };
}

Live collections live in their own file, src/live.config.ts, and use defineLiveCollection():

// src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { CMS_URL, CMS_TOKEN } from 'astro:env/server';
import { cmsLiveLoader } from './loaders/cms-live-loader';

const livePosts = defineLiveCollection({
  loader: cmsLiveLoader({ url: CMS_URL, token: CMS_TOKEN }),
});

export const collections = { livePosts };

The preview page opts out of prerendering and calls getLiveEntry(), which returns an entry and an error:

---
// src/pages/preview/[slug].astro
export const prerender = false;

import { getLiveEntry } from 'astro:content';

const { entry, error } = await getLiveEntry('livePosts', Astro.params.slug!);

if (error || !entry) {
  return Astro.rewrite('/404');
}
---
<html lang="en">
  <head><meta charset="utf-8" /><title>Preview: {entry.data.title}</title></head>
  <body>
    <h1>{entry.data.title}</h1>
    <p>{entry.data.excerpt}</p>
  </body>
</html>

Running the built server with CMS_TOKEN set, /preview/hello-astro/ rendered the current CMS data, and an unknown slug returned a 404. The static blog pages kept working alongside it, since only the preview route renders on demand.

In a real project, point the preview loader at your CMS's draft or preview API and protect the route, for example with a secret query parameter checked in middleware or with authentication, so drafts aren't public. Live loaders can also return cache hints, which Astro 7 can pass to Astro.cache.set() to cache responses; the post on caching on-demand pages shows how.

Using an Official SDK or Integration Instead

You don't have to write a loader. Many CMSs publish their own tools:

  • SDKs: Contentful's guide, for example, creates a client with contentful.createClient() in src/lib/contentful.ts and calls getEntries() in the frontmatter of a page. It's quick to start with, though you lose collection features like schema validation and getCollection() unless you wrap the SDK in a loader.
  • Integrations: Storyblok provides @storyblok/astro, added to integrations with an access token and a map of Storyblok components to your Astro components, so editors can compose pages from blocks.
  • Loader packages: several CMSs and community authors publish ready-made content loaders. Search the integrations directory for the "Content Loaders" category.

A good pattern is to use the vendor's SDK inside your own loader. You get their authentication, pagination and typing helpers, and Astro's collections, validation and caching between builds.

Practical Tips

  • Handle pagination: many APIs return 25 or 100 items per page. Loop until you've fetched everything, or your build will silently miss older posts.
  • Validate strictly: a schema that requires title catches an editor saving an empty draft before it becomes a broken page.
  • Optimise CMS images: add the CMS's image domain to image.domains (or image.remotePatterns) so the <Image /> component can optimise remote images, or use the CMS's own image transformation parameters.
  • Use the content cache: Astro keeps the content store between builds, and loaders can use meta to store a "last synced" value and request only changed entries, which speeds up large sites.
  • Keep rich text safe: if the CMS returns HTML that editors can write, treat it as trusted only if your editors are. Otherwise sanitise it before using set:html.

FAQ: Headless CMS with Astro

Any CMS with an HTTP API works, and Astro's docs include guides for many of them, including Contentful, Sanity, Storyblok, Strapi, Prismic and WordPress. Choose based on your editors' needs, pricing and hosting rather than Astro compatibility, since the integration pattern is the same.

No. With a build-time content collection, Astro fetches CMS content during the build and outputs static HTML. You only need an adapter for on-demand rendering if you want content to update without a rebuild, such as for previews or live collections.

Create a build hook or deploy hook URL on your hosting platform and add it as a webhook in your CMS for publish and delete events. Each change then triggers a fresh build that fetches the latest content.

Declare it as a server secret with astro:env, set it in a .env file locally and in your host's environment settings for production. Never use a PUBLIC_ prefix or a client context for it, as those values can end up in browser code.

Yes. In a loader, call renderMarkdown() for Markdown bodies, or set rendered.html for HTML bodies. You can then use render() and the Content component in your pages, just as with local Markdown files.

A loader runs at build time and stores entries in Astro's content store, which you query with getCollection(). A live loader runs on every request, returns data directly, requires an adapter and is queried with getLiveCollection() and getLiveEntry().


Conclusion

Using a headless CMS with Astro comes down to turning API responses into a content collection. A small loader with a Zod schema, parseData() and renderMarkdown() gives you validated, typed entries that work with getCollection(), getStaticPaths() and render(), while astro:env keeps your token on the server. A build hook connected to CMS webhooks keeps the static site up to date.

When editors need to see changes instantly, add an adapter and a live collection for preview routes, keeping the rest of the site static. Whether you write your own loader or use the vendor's SDK or integration, the same structure applies, and switching CMS later mostly means rewriting one loader rather than your whole site.

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