Using a headless CMS
The template does not need a CMS: content lives in src/content/ and you edit it like any other file
(Editing the content). Connect one when somebody who does not use a code editor has to publish — a marketing
colleague adding posts, a recruiter opening a role.
Either way the pages, sections and components stay exactly as they are. Only where the data comes from changes.
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):
posts: defineCollection({ loader: jsonArray("posts.json", "slug"), schema: postSchema.extend(position) }),Pages never read the files directly; they call src/lib/content.ts (getPosts(), getProject(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 at build time — a missing field fails the build with a clear message instead of breaking a page.
posts: defineCollection({ loader: async () => { const response = await fetch(`${import.meta.env.CMS_URL}/api/posts?limit=200&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:
getPost(slug) looks entries up by it.
Page copy (src/content/pages/) and site details (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:
| Collection | Key fields |
|---|---|
projects |
slug, title, year, image, services[{label, slug}], summary, industry, overview, process, result |
services |
slug, title, description, benefits, process steps, pricing plans, FAQ |
posts |
slug, title, excerpt, image, category, date, body (blocks) |
careers |
slug, title, summary, location and type, responsibilities, requirements |
team, testimonials |
name, role, email, photo / quote, name, company, service, photo, logo |
Two rules save trouble later: slug is the URL, so make it unique and required; keep the collection’s order in the
CMS (a sort field) and return entries in that order — position preserves it.
Rich text
Section titled “Rich text”Post bodies are blocks, not HTML:
{ type: "heading", text: "…", bold?: "…" }{ type: "paragraph", highlight?: "…", text: "…" }{ type: "list", ordered?: true, items: Block[][] }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 at build time:
export default defineConfig({ image: { domains: ["cdn.sanity.io"] }, // or your Payload / Strapi / Directus host // …});Without it the build stops with Remote image not allowed and names the URL.
The four people usually ask about
Section titled “The four people usually ask about”| CMS | How it fits |
|---|---|
| Sanity | Hosted, generous free tier. The official @sanity/astro integration gives you a client; query with GROQ inside the loader and convert Portable Text to blocks. |
| Payload | Self-hosted (Node + Postgres or Mongo). Its REST API returns docs, which map almost one to one to the schemas. |
| Strapi | Self-hosted admin, REST or GraphQL. Model the content types by hand and fetch in the loader. |
| Directus | An admin on top of a SQL database you already have. Fetch through its REST API or SDK. |
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 presses Publish, and visitors still get plain, fast HTML. 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 posts and careers (the things that change weekly) while services, projects and the page copy stay in the repository. Collections are independent — replace one loader and leave the others on their JSON files.