Fetching from a Headless CMS
A headless CMS gives editors a friendly authoring interface while exposing content through an API, leaving you free to render it however you like. Astro is an ideal front end for this pattern: it fetches CMS data at build time (or on demand), renders it to static HTML, and ships zero JavaScript by default. This page shows how to pull entries from popular CMSs like Contentful, Sanity, and Storyblok, and how to model that content as Astro content collections.
Why Astro pairs well with a headless CMS
Because most CMS content changes infrequently between deploys, fetching it during the build is the natural fit. Astro runs your component frontmatter on the server, so an API call there never reaches the browser — no API keys leak, no loading spinners, and the visitor gets pre-rendered HTML. When content does change often, you switch a page (or the whole project) to on-demand rendering and the same fetch call runs per request instead.
Keep CMS API tokens in environment variables (a
.envfile or your host’s secret manager), not in committed source. Astro exposes server-only variables throughimport.meta.envand never bundles them into client output.
Fetching CMS data in the frontmatter
The simplest integration is a plain fetch in a page’s component script. Astro awaits top-level promises in the frontmatter, so the page renders only once the data resolves.
---
// src/pages/blog/index.astro
const SPACE = import.meta.env.CONTENTFUL_SPACE_ID;
const TOKEN = import.meta.env.CONTENTFUL_DELIVERY_TOKEN;
const res = await fetch(
`https://cdn.contentful.com/spaces/${SPACE}/entries?content_type=blogPost&order=-fields.publishedAt`,
{ headers: { Authorization: `Bearer ${TOKEN}` } }
);
const { items } = await res.json();
---
<ul>
{items.map((post) => (
<li>
<a href={`/blog/${post.fields.slug}`}>{post.fields.title}</a>
</li>
))}
</ul>
This runs entirely on the server. The rendered list is static HTML with no client-side JavaScript.
Generating pages with getStaticPaths
To turn CMS entries into individual routes, fetch the full list inside getStaticPaths and return one path per entry. Astro then statically builds every page at deploy time.
---
// src/pages/blog/[slug].astro
export async function getStaticPaths() {
const SPACE = import.meta.env.CONTENTFUL_SPACE_ID;
const TOKEN = import.meta.env.CONTENTFUL_DELIVERY_TOKEN;
const res = await fetch(
`https://cdn.contentful.com/spaces/${SPACE}/entries?content_type=blogPost`,
{ headers: { Authorization: `Bearer ${TOKEN}` } }
);
const { items } = await res.json();
return items.map((post) => ({
params: { slug: post.fields.slug },
props: { post },
}));
}
const { post } = Astro.props;
---
<article>
<h1>{post.fields.title}</h1>
<p>{post.fields.summary}</p>
</article>
Output:
[build] 12 page(s) built in 410ms
/blog/getting-started/index.html
/blog/deploying-astro/index.html
...
Using official SDKs
Most CMSs publish a client library that handles auth, pagination, and typed queries. Install it and call it from the frontmatter exactly as you would fetch.
npm install @sanity/client
// src/lib/sanity.ts
import { createClient } from "@sanity/client";
export const sanity = createClient({
projectId: import.meta.env.SANITY_PROJECT_ID,
dataset: "production",
apiVersion: "2025-01-01",
useCdn: true,
});
---
// src/pages/index.astro
import { sanity } from "../lib/sanity";
const posts = await sanity.fetch(
`*[_type == "post"] | order(publishedAt desc){ title, "slug": slug.current }`
);
---
<ul>
{posts.map((p) => <li><a href={`/blog/${p.slug}`}>{p.title}</a></li>)}
</ul>
Content collections with a loader
Astro 5’s Content Layer lets you register a CMS as a collection source via a loader. Some CMSs ship official loaders; otherwise you can write an inline loader that fetches and returns entries. Collections give you typed entries, schema validation with Zod, and the getCollection / getEntry helpers.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { storyblokLoader } from "@storyblok/astro";
const blog = defineCollection({
loader: storyblokLoader({
accessToken: import.meta.env.STORYBLOK_TOKEN,
version: "published",
}),
schema: z.object({
title: z.string(),
publishedAt: z.string(),
}),
});
export const collections = { blog };
---
// src/pages/articles.astro
import { getCollection } from "astro:content";
const articles = await getCollection("blog");
---
<ul>
{articles.map((a) => <li>{a.data.title}</li>)}
</ul>
Schema validation runs at build time. If an editor publishes an entry missing a required field, the build fails loudly with the offending field name — catching content errors before they ship.
Choosing an integration approach
| Approach | Best for | Typing | Rebuild on content change |
|---|---|---|---|
Raw fetch in frontmatter | Quick start, any CMS | Manual | Yes (or SSR) |
| Official SDK | Rich queries, auth handling | SDK-provided | Yes (or SSR) |
| Content collection loader | Many entries, validation | Zod schema | Yes |
SSR (output: 'server') | Always-fresh content | Any of the above | No — fetched per request |
Keeping content fresh
For always-current data, enable on-demand rendering with an adapter and set the page or project to render on the server. The same fetch then runs per request:
---
// src/pages/live-feed.astro
export const prerender = false; // render this page on demand
const res = await fetch("https://cdn.contentful.com/spaces/.../entries");
const { items } = await res.json();
---
To stay static while still updating on publish, trigger a deploy from your CMS’s webhook (most platforms support build hooks). This keeps the zero-JS, fully cached output while refreshing content automatically.
Best Practices
- Store every API key in environment variables and read them via
import.meta.env, never hard-coded. - Prefer build-time fetching for content that changes between deploys; reserve SSR (
prerender = false) for genuinely live data. - Use content collections with a Zod schema so malformed CMS entries fail the build instead of breaking pages silently.
- Use a CDN-cached delivery endpoint (
useCdn: true,cdn.contentful.com) for reads to keep builds fast. - Wire a CMS publish webhook to your host’s build hook so static output regenerates automatically on content changes.
- Centralize CMS client setup in a
src/lib/module so queries stay DRY and reusable across pages.