Skip to content
Sign inJoin waitlist

Use a headless CMS with a Next.js template

Every Framebrick Next.js 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, their fields and the functions that read them — is on the template’s own Using a headless CMS page, in its docs under Headless CMS (for example Nitip’s).

Pages never import the collections directly. They call src/lib/content.ts, which has a pair of functions for each collection — the list and a single item:

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

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 and every list, filter and search, which build themselves 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
});

The single-item functions can keep searching the list, as they do now, or ask the CMS for one entry. cache makes each call run once per request, even when a page and its metadata ask for the same entry.

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: one collection in the CMS for each type the functions return. A few rules save trouble later:

  • slug is 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 type expects (the slug, sometimes a label too) in your mapping function.
  • Keep the collection’s order in the CMS (a sort field): the site shows items in the order it receives them, and the home page shows the first few.
  • Make a field required in the CMS when the type requires it, images included, so an editor can’t publish an item the page can’t show.

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/types.ts have the exact shapes. 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. While the CMS runs on your own machine, Next.js also refuses images from a private address until you allow it (images.dangerouslyAllowLocalIP, shown in the Strapi and Directus guides). Upload images at least as large as the layout uses; the template’s CMS page gives the sizes.

CMS How it fits Step by step
Sanity Hosted, generous free tier. Query with GROQ in your mapping functions; editors work in Sanity Studio, which can live inside the site. Good when editors want live preview. Sanity + Next.js
Payload Runs inside the same Next.js app, with a database you own. Collections map almost one to one to types.ts. Good when you want everything in one deployment. Payload + Next.js
Strapi Free and open source, a Node app of its own next to the site. Good when the client already runs a server. Strapi + Next.js
Directus An admin on top of a SQL database you own, self-hosted in Docker. Good when the content also feeds something else. Directus + Next.js

The guides were tested on Nitip; the steps are the same for every template, with its own collections. 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 The CMS tells the site after a save: a route that calls revalidatePath() (a hook, with Payload in the same app) — what the guides set up 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, set dynamicParams = true, so an item published after the build renders on its first visit; unknown slugs are still a 404.

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