Skip to content

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.

Pages never import the collections directly. They call src/lib/content.ts:

export const getProjects = cache(async (): Promise<Project[]> => projects);
export const getProject = cache(async (slug: string) => (await getProjects()).find((item) => item.slug === slug));
export const getPosts = cache(async (): Promise<Post[]> => posts);

To move to a CMS, rewrite these functions to fetch from it and return the same types (src/content/types.ts). Keep the names, the arguments and the return types and nothing else in the project has to change — including sitemap.ts, which builds itself from the same functions.

export const getPosts = cache(async (): Promise<Post[]> => {
const entries = await fetch(`${process.env.CMS_URL}/posts`, { headers: { … } }).then((r) => r.json());
return entries.map(toPost); // your mapping, one function per collection
});

Page copy (src/content/pages/) and site details (src/content/site.ts) 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/types.ts. The demo’s shapes are:

Collection Key fields
Project slug, title, year, image, services[{label, slug}], summary, industry, overview, process, result
Service slug, title, description, benefits, process steps, pricing plans, FAQ
Post slug, title, excerpt, image, category, date, body (blocks)
Career slug, title, summary, location and type, responsibilities, requirements
TeamMember, Testimonial 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) because the site shows items in the order it receives them.

Post bodies are blocks, not HTML:

{ type: "heading", text: "", bold?: "" }
{ type: "paragraph", highlight?: "", text: "" }
{ type: "list", ordered?: true, items: PostBlock[][] }

Every CMS has its own rich-text format (Portable Text in Sanity, Lexical or Slate in Payload, HTML in Strapi and Directus). Convert it to these blocks in your mapping function, or extend src/design-system/components/patterns/RichText.tsx with the block types you need.

Content refers to images as /images/<file> in public/. A CMS gives you URLs on its own domain instead:

  1. Return the CMS URL from your mapping function.
  2. Allow that domain in next.config.ts:
    images: { remotePatterns: [{ protocol: "https", hostname: "cdn.sanity.io" }] },

next/image then resizes and serves them as before. Ask the CMS for images at least as large as the layout uses (2000 px for heroes and project images).

CMS How it fits
Sanity Hosted, generous free tier. Query with GROQ in your mapping functions; convert Portable Text to the block format. Good when editors want live preview.
Payload Runs inside the same Next.js app or beside it, with a Postgres or Mongo database you own. Collections map almost one to one to types.ts. Good when you want everything in one deployment.
Strapi Self-hosted Node admin, REST or GraphQL. Model the content types by hand, fetch with fetch. Good when the client already runs a server.
Directus Puts an admin on top of a SQL database you already have. Fetch through its REST API. Good when the content also feeds something else.

Any other headless CMS (Contentful, Storyblok, Hygraph, Prismic, Keystone, a WordPress REST API) works the same way: fetch in src/lib/content.ts, map to the types.

Pages are pre-rendered, which is why the site is fast. Choose how new content reaches visitors:

Approach What to do Feels like
Rebuild on publish Add a webhook in the CMS that calls your host’s deploy hook New content in a minute or two
Revalidate on a timer export const revalidate = 600; in the page file New content within 10 minutes, no rebuild
Revalidate on demand A route that calls revalidateTag() when the CMS posts to it New content in seconds
Always fresh export const dynamic = "force-dynamic"; Slower pages; only for something truly live

Collection pages also list their slugs in generateStaticParams() and set dynamicParams = false. With a CMS, either leave it (new items appear on the next build or revalidation) or set dynamicParams = true so a brand-new slug is rendered on first request.

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. src/lib/content.ts can fetch some collections and return local ones for the rest — the functions are independent.