Skip to content
Astro as data 4 min read

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 .env file or your host’s secret manager), not in committed source. Astro exposes server-only variables through import.meta.env and 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

ApproachBest forTypingRebuild on content change
Raw fetch in frontmatterQuick start, any CMSManualYes (or SSR)
Official SDKRich queries, auth handlingSDK-providedYes (or SSR)
Content collection loaderMany entries, validationZod schemaYes
SSR (output: 'server')Always-fresh contentAny of the aboveNo — 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.
Last updated June 14, 2026
Was this helpful?