Build an Astro site with a headless CMS: a practical guide
TL;DR
To pair a headless CMS with Astro: fetch content at build time in the frontmatter, generate routes with getStaticPaths, render with Astro components, and serve images from the CMS CDN. You get a fast static site with no client-side content fetching.
A CMS for Astro, whether Adapto CMS or another, connects the same way you'd connect any REST API: fetch content in your page frontmatter (or getStaticPaths) at build time, using top-level await since Astro components run server-side during the build. No client-runtime CMS calls, no loading spinners, no SDK you have to learn first. You call fetch() against the CMS's REST endpoints and use Astro's own templating. The rest of this guide walks through connecting the API, modeling content, rendering static and dynamic pages, handling images and locales, and deploying.
Why Astro + a headless CMS
Astro ships zero JavaScript by default and renders to static HTML at build time. For a content-driven site (blog, docs, marketing pages, product catalog), that's the execution model a headless CMS wants: fetch once when you build, not on every visitor's request. Two things line up:
- Content-first, not app-first. Astro's islands architecture assumes most of a page is static markup; the CMS is the source of truth for that markup, and Astro's job is to fetch it once and render it.
- SEO comes free. Static HTML at every route means crawlers see fully-rendered content with no JS execution required. No hydration mismatch, no client-side content flash while data loads.
A static-first framework and a build-time content fetch fit this use case naturally. The tradeoff to name: content changes don't appear until the next build, so you need a rebuild trigger (a webhook from the CMS to your host, or a scheduled build) if editors expect near-real-time publishing.
Setup: connect the API, model your content
Two things before you write a page: an API key and a content model.
Connect the API. Store credentials in .env, never commit them:
# .env
ADAPTO_API_KEY=your-api-key
ADAPTO_API_URL=https://public-api.adaptocms.comThe key goes in an x-api-key header rather than a bearer token. Get this right the first time: a mismatched header shape returns a 401 with no hint about why. ADAPTO_API_KEY is build-time only: it's read during astro build, never bundled into client-side JavaScript, so there's no risk of it leaking into a browser request.
A small fetch wrapper keeps the rest of the codebase from repeating auth headers:
// src/lib/adapto.js
const BASE_URL = import.meta.env.ADAPTO_API_URL;
const API_KEY = import.meta.env.ADAPTO_API_KEY;
export async function adaptoFetch(path) {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { "x-api-key": API_KEY },
});
if (!res.ok) {
throw new Error(`Adapto API error ${res.status}: ${path}`);
}
return res.json();
}That throw matters more than it looks. If a build-time fetch fails silently and falls back to an empty array, you ship a live site with missing pages that still return HTTP 200. Fail the build instead.
Model your content. Before writing a single Astro page, define the shape of what you're fetching: an Article type with fields like title, slug, body, cover_image, category, published_at, whatever the site needs. Get this step right early: bolting fields on later means re-fetching and re-templating every page that already uses that type. The data models docs and the API content modeling guide cover the mechanics (references, components, reuse) in more depth than fits here.
Fetch at build time, render pages, dynamic routes
For a static list page, fetch directly in the component frontmatter:
// src/pages/articles/index.astro
import { adaptoFetch } from "../../lib/adapto";
const { items: articles } = await adaptoFetch("/v1/articles?status=published");
<ul>
{articles.map((article) => (
<li><a href={`/articles/${article.slug}/`}>{article.title}</a></li>
))}
</ul> The response is paginated as { items, total, page, limit, pages }, so items is what you map over. The default page size covers most blogs in one call; past a few hundred articles, bump limit or loop pages until items.length comes back short.
For per-article pages, getStaticPaths is the piece that makes Astro static: it runs once at build time, fetches every article, and tells Astro which routes to pre-render.
// src/pages/articles/[slug].astro
import { adaptoFetch } from "../../lib/adapto";
export async function getStaticPaths() {
const { items: articles } = await adaptoFetch("/v1/articles?status=published");
return articles.map((article) => ({
params: { slug: article.slug },
props: { article },
}));
}
const { article } = Astro.props;
<article>
<h1>{article.title}</h1>
<Fragment set:html={article.body} />
</article> Every entry returned from getStaticPaths becomes a route at build time: a hundred articles means a hundred static HTML files, generated once and served from a CDN with no per-request CMS call. That's the whole appeal. The CMS gets hit during astro build, not by every visitor.
Images, i18n, and deploying
Images. If the CMS bundles a media CDN, skip Astro's built-in image pipeline for CMS-sourced images and use the CDN's own resize/format parameters directly in the URL. You get on-the-fly transforms without asking Astro to download and reprocess every image at build time. Adapto's media CDN serves assets from media.adaptocms.com and takes four query params (w, h, format, quality), with the optimization itself bundled on every tier, no add-on to enable:
https://media.adaptocms.com/uploads/cover.jpg?w=800&h=600&format=webp&quality=100 In an Astro template, build that URL from the field the API already returns:
<img
src={`${article.cover_image.url}?w=800&h=450&format=webp&quality=100`}
width="800"
height="450"
alt={article.cover_image.alt}
loading="lazy"
/> i18n. Astro's i18n config (astro.config.mjs → i18n.locales) tells Astro which locales are valid and how to prefix URLs, but it doesn't create per-locale routes by itself. Astro's i18n routing is directory-based: a [locale] folder in src/pages, with its own getStaticPaths returning one entry per locale, is what produces /en-US/articles/ and /ro-RO/articles/ as separate static routes.
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: "en-US",
locales: ["en-US", "ro-RO"],
},
});// src/pages/[locale]/articles/index.astro
import { adaptoFetch } from "../../../lib/adapto";
export async function getStaticPaths() {
return [{ params: { locale: "en-US" } }, { params: { locale: "ro-RO" } }];
}
const { locale } = Astro.params;
const { items: articles } = await adaptoFetch(`/v1/articles?status=published&language=${locale}`);
<ul>
{articles.map((article) => (
<li><a href={`/${locale}/articles/${article.slug}/`}>{article.title}</a></li>
))}
</ul> The query param is language, even though the route segment is named locale: name that segment however fits your URL structure, but the CMS filters on language=<code>. This is the minimal version, enough to fetch per-locale content and generate one route per locale. Astro's own i18n guide covers the rest of the plumbing (Astro.currentLocale, hreflang tags, x-default fallback routing), and the official Astro starter ships all of it wired end to end rather than hand-assembled from a blog post.
Deploy. Set output: 'static' (Astro's default), run astro build, and deploy the output to any static host or CDN. The one piece worth wiring deliberately: a webhook from the CMS that triggers a rebuild when content is published, so editors don't have to ask a developer to redeploy every time. Without it, "static" means "static until the next manual deploy." That works for a docs site that changes weekly. A newsroom needs the webhook.
Next steps
Adapto CMS ships an official Astro starter that wires up this fetch pattern, routing, and deploy config out of the box. Clone it before you hand-roll the above. If you're still deciding whether Astro is the right frontend for a CMS-driven site at all, versus Next.js or SvelteKit, the Astro CMS page covers that decision directly. Once pages are rendering, the next real work is usually the content model itself. The API content modeling guide is the deeper read on structuring types that scale past the first dozen pages.
FAQ
What's the best headless CMS for Astro? There's no single "best," but the criteria that matter for Astro specifically: a REST API (Astro's build-time fetch pattern is plain fetch(), so GraphQL-only lock-in buys you nothing extra), flat-rate pricing (a large static site can make hundreds of API calls in one build, and metered-by-call pricing turns every rebuild into a cost line), and a bundled media CDN (so Astro's image pipeline isn't reprocessing every asset at build time). Adapto CMS fits that shape (REST-first, flat-rate, bundled media CDN), but the criteria matter more than any one vendor's name.
How do I fetch CMS content in Astro? With a plain fetch() call inside an Astro component's frontmatter or a getStaticPaths() function. Both run at build time, not in the browser, so await works directly with no client-side data-fetching library needed. For a list page, fetch and map over the results in the template; for per-item pages, fetch inside getStaticPaths() and return one route per item with the item's data passed as props.