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).
Where collections come from
Section titled “Where collections come from”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.
What to model in the CMS
Section titled “What to model in the CMS”Mirror src/content/schemas.ts: one collection in the CMS for each collection in src/content.config.ts. A few rules
save trouble later:
slugis 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.
Rich text
Section titled “Rich text”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.
Images
Section titled “Images”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.
Which CMS
Section titled “Which CMS”| 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.
Keeping the site fresh
Section titled “Keeping the site fresh”The site is static: the CMS is read when the site builds, not when someone visits. Publish new content by rebuilding:
- In your host (Vercel, Netlify, Cloudflare Pages), create a deploy hook — a URL that starts a build.
- 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.
Keeping both
Section titled “Keeping both”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.