Skip to content
Sign inJoin waitlist

Use a headless CMS with an Astro template

Every Framebrick Astro template is built the same way, so connecting a headless CMS works the same for all of them. The template doesn’t need one: content lives in src/content/ and you edit it like any other file. Connect a CMS when somebody who doesn’t use a code editor has to publish — a colleague adding posts, a client adding projects. Either way the pages, sections and components stay exactly as they are; only where the data comes from changes.

What differs from one template to the next — its collections and their fields — is on the template’s own Using a headless CMS page, in its docs under Headless CMS (for example Nitip’s).

src/content.config.ts defines each collection with a loader (where the entries come from) and a schema (what they must look like). In the demo, the loader reads a JSON file in src/content/collections/, for example:

posts: defineCollection({ loader: jsonArray("posts.json", "slug"), schema: postSchema.extend(position) }),

Pages never read the files directly; they call src/lib/content.ts (getPosts(), getPost(slug), …), which reads the collections. So moving to a CMS means replacing a loader. The schema stays, and it checks every entry the CMS sends — a missing field stops the build with a message that names the entry, instead of breaking a page.

posts: defineCollection({
loader: async () => {
const response = await fetch(`${import.meta.env.CMS_URL}/api/posts?sort=position`, {
headers: { Authorization: `Bearer ${import.meta.env.CMS_TOKEN}` },
});
const { docs } = await response.json();
return docs.map((doc, position) => ({ id: doc.slug, position, ...toPost(doc) }));
},
schema: postSchema.extend(position),
}),

toPost is your mapping from the CMS’s fields to the shape in src/content/schemas.ts. Keep id equal to the slug, because the single-item functions (getPost(slug), …) look entries up by it; collections without a slug, such as team members, are keyed by name. Keep position in the CMS’s order too: getCollection doesn’t keep the order, so src/lib/content.ts sorts by it.

Page copy (src/content/pages/) and site details (src/content/site.json) can stay in the repository even when the collections come from a CMS — they change once a year, not once a week.

Mirror src/content/schemas.ts: one collection in the CMS for each collection in src/content.config.ts. A few rules save trouble later:

  • slug is the URL, so make it unique and required.
  • When an item points to another — a project to its services, a property to its agent — model a reference, and return what the schema expects (the slug, sometimes a label too) in your mapping.
  • Keep the collection’s order in the CMS (a sort field) and return entries in that order: the site shows them in that order, and the home page shows the first few.
  • Make a field required in the CMS when the schema requires it, images included, so an editor can’t publish an entry that stops the build.

Post bodies — and on some templates descriptions, legal texts or a service’s “What’s included” — are blocks, not HTML:

{ type: "heading", text: "…" }
{ type: "paragraph", text: "…" }
{ type: "list", ordered?: true, items: … }

Each template adds a few details, such as a bold start or links inside the text; its CMS page and src/content/schemas.ts have the exact shapes. Every CMS has its own rich-text format: Portable Text in Sanity, Lexical in Payload, HTML or Markdown in Strapi and Directus. Convert it to these blocks in your mapping, or extend src/design-system/components/patterns/RichText.astro with the block types you need.

Demo content refers to images as /images/<file> in src/assets/images/. A CMS gives full URLs instead, and the image components accept those too. Allow the CMS’s image domain in astro.config.mjs so Astro can download, resize and convert them when it builds:

export default defineConfig({
image: { domains: ["cdn.sanity.io"] }, // or your Payload, Strapi or Directus host
// …
});

Without it the build stops with Remote image … is not allowed by your image configuration and names the URL. When an image is missing, map it to undefined rather than an empty string: the schema then names the entry, instead of the build failing later on a page. Upload images at least as large as the layout uses; the template’s CMS page gives the sizes.

CMS How it fits Step by step
Sanity Hosted, generous free tier. Query with GROQ inside the loader; editors work in Sanity Studio. Sanity + Astro
Payload A self-hosted Node app with a database you own. Its REST API returns docs, which map almost one to one to the schemas. Payload + Astro
Strapi Free and open source, self-hosted next to the site. Model the content types once and fetch in the loader. Strapi + Astro
Directus An admin on top of a SQL database you own, self-hosted in Docker. Directus + Astro

The guides were tested on Nitip; the steps are the same for every template, with its own collections. Anything else with an API works the same way — Contentful, Storyblok (@storyblok/astro), Hygraph, Prismic, Keystone, WordPress’s REST API, a Google Sheet: fetch in a loader, map to the schema.

The site is static: the CMS is read when the site builds, not when someone visits. Publish new content by rebuilding:

  1. In your host (Vercel, Netlify, Cloudflare Pages), create a deploy hook — a URL that starts a build.
  2. In the CMS, add a webhook on publish that calls that URL.

New content is live a minute or two after an editor publishes, and visitors still get plain, fast HTML. While you work, the guides add a small integration so a publish reloads the collections in npm run dev without a restart. If you need pages that update on every request instead, add an adapter (npx astro add vercel) and set export const prerender = false on those pages only.

A useful middle ground: the CMS owns the collections that change every week — posts, roles, case studies — while the rest and the page copy stay in the repository. Collections are independent: replace one loader and leave the others on their JSON files.