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.
The one file that matters
Section titled “The one file that matters”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.
What to model in the CMS
Section titled “What to model in the CMS”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.
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: 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.
Images
Section titled “Images”Content refers to images as /images/<file> in public/. A CMS gives you URLs on its own domain instead:
- Return the CMS URL from your mapping function.
- 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).
The four people usually ask about
Section titled “The four people usually ask about”| 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.
Keeping the site fresh
Section titled “Keeping the site fresh”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.
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. src/lib/content.ts can fetch some collections and return local ones for the
rest — the functions are independent.