Skip to content
Docs
Sign inGet Pro

Using a headless CMS — Findit Astro

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 — an agent adding a listing, a colleague writing posts.

Either way the pages, sections and components stay exactly as they are. Only where the data comes from changes.

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 (getProperties(), getAgent(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.

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

toProperty is your mapping from the CMS’s fields to the shape in src/content/schemas.ts. Keep id equal to the slug: getProperty(slug) looks entries up by it (the team collection uses the member’s name).

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.

Mirror src/content/schemas.ts:

Collection Key fields
properties slug, title, propertyId, for (sell or rent), featured, category, agent, location (slugs), address, price, duration, image, gallery (four photos), video, description, features, details (size, bedrooms, bathrooms, floor, …), utility (heating, air condition, …)
agents slug, name, image, address, bio, phone, mobile, whatsapp, email
locations, categories slug, name, and for locations an image
posts slug, title, category, date, author, image, excerpt, body
legal slug, title, description, updated, body
testimonials, team name, photo, rating, title, quote / name, role, bio, photo

Three rules save trouble later:

  • slug is the URL, so make it unique and required.
  • A property refers to its agent, location and category by their slug — model them as references and return the slug in your mapping. A property whose agent, location or category is missing stops the build and names it.
  • Keep the collection’s order in the CMS (a sort field) and return entries in that order — position preserves it (the home page’s Featured section shows the first five properties that are featured).

Descriptions, features, posts and legal texts are blocks, not HTML:

{ type: "heading", text: "…" }
{ type: "paragraph", text: "…" } // **bold** and [links](/path) inside the text
{ type: "list", ordered?: true, items: ["…", "…"] }

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 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.

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 — good when the listings also feed a property database or an MLS export.

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 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.

A useful middle ground: the CMS owns properties and posts (the things that change weekly) while agents, locations, categories and the page copy stay in the repository. Collections are independent — replace one loader and leave the others on their JSON files.