Type something to search...
Using a Headless CMS with Next.js: Sanity, Contentful, and Strapi Compared

Using a Headless CMS with Next.js: Sanity, Contentful, and Strapi Compared

Markdown files in a Git repository are a great way to run a developer blog. They stop being great the moment someone who doesn't use Git needs to publish a page, fix a typo, or schedule a launch. That's when teams reach for a headless CMS: a content editing interface and an API, with no opinion about how the front end renders anything. Next.js handles the rendering.

There are dozens of options, but three come up in almost every evaluation: Sanity, Contentful, and Strapi. They solve the same problem in very different ways. Sanity is a hosted content lake with a customizable editor you configure in code. Contentful is a polished, fully managed enterprise platform. Strapi is open source and runs on your own infrastructure (or on Strapi Cloud).

In this post I'll compare them on the things that matter in day-to-day work, then show how to integrate each one with the Next.js 16 App Router: fetching content, caching it, revalidating it on publish with webhooks, and previewing drafts. I'll use a common Post type so you can see how the same page looks with each backend.

The Short Version

SanityContentfulStrapi
HostingHosted content store; editor (Studio) can be embedded in your Next.js app or deployed separatelyFully hosted SaaSSelf-hosted Node.js app, or Strapi Cloud
Open sourceStudio is open source; backend is hostedNoYes (MIT for the core)
Content modelingSchemas defined in TypeScript codeDefined in the web UI (or via migration scripts)Content-Type Builder UI, saved as JSON files in your repo
Query languageGROQ (also GraphQL)REST and GraphQLREST and GraphQL
Rich text formatPortable Text (structured JSON)Rich Text (structured JSON)Blocks editor (JSON) or Markdown
Real-time collaborationYes, built inLimitedNo
Draft previewDraft perspectives, visual editing toolsSeparate Preview APIDraft and publish status in the API
Pricing modelGenerous free tier; usage and seats beyond thatFree tier with limits; paid plans aimed at teams and enterprisesFree to self-host; pay for hosting or Strapi Cloud
Best fitDeveloper-led teams that want a tailored editing experienceLarger organizations that want a managed platform with governance featuresTeams that want full control, self-hosting, or a custom backend

Pricing and plan limits change often, so check each vendor's current pricing page before deciding. The structural differences above are more stable.

Choosing Between Them

The comparison table hides the questions that usually decide the choice.

Who maintains the content model? In Sanity, schemas are TypeScript files in your repository, so content model changes go through code review like everything else. In Contentful, models are usually edited in the web app, which suits teams where non-developers own the structure, though serious teams script changes with Contentful's migration tooling. Strapi sits in between: you edit models in a UI during development, and Strapi writes them to files you commit.

Where can the data live? If you have strict data residency requirements, or you simply don't want a third party holding your content, Strapi is the only one of the three you can run entirely on your own servers and database.

How much editor customization do you need? Sanity Studio is a React application. You can add custom input components, document actions, dashboards, and structure the navigation however you like. Contentful offers an app framework for extensions. Strapi supports custom fields and plugins, with more work.

How much operations work will you accept? Contentful and Sanity need none. Self-hosted Strapi means running a Node.js server and a database, handling upgrades, backups, and media storage. Strapi Cloud removes most of that.

What does your team already know? GROQ is powerful but new to most developers. REST with filter parameters (Contentful, Strapi) or GraphQL (all three) is more familiar.

A Shared Shape for Your Pages

Whichever CMS you pick, keep CMS-specific code in one module and have it return your own types. Pages then depend on Post, not on a vendor's response format, and switching CMS later only touches the adapter.

// src/lib/cms/types.ts
export type Post = {
  slug: string;
  title: string;
  excerpt: string;
  publishedAt: string;
  coverImageUrl: string | null;
  body: unknown; // Rich text JSON, rendered by a CMS-specific component
};

Each section below implements getPost(slug) and getPostSlugs() for one CMS.

Caching Strategy

The examples assume Cache Components is enabled (cacheComponents: true in next.config.ts), and cache CMS reads with the "use cache" directive, tagged so a webhook can invalidate them:

  • cacheLife("max") keeps content cached for a long time, since you'll invalidate it explicitly on publish.
  • cacheTag("posts") tags every post read, and cacheTag("post:<slug>") tags each individual post.
  • When content is published, a webhook calls revalidateTag with those tags.

That gives you static-site speed with near-instant updates. If you're not using Cache Components, the same idea works with fetch(url, { next: { tags: [...] } }) and the older caching model; see On-Demand Revalidation in Next.js.

Integrating Sanity

Install the official toolkit, which wraps the Sanity client and adds Next.js helpers:

npm install next-sanity @portabletext/react
// src/lib/cms/sanity.ts
import "server-only";
import { createClient } from "next-sanity";
import { cacheLife, cacheTag } from "next/cache";
import type { Post } from "./types";

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET ?? "production",
  apiVersion: "2025-01-01",
  useCdn: true,
});

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{
  "slug": slug.current,
  title,
  excerpt,
  publishedAt,
  "coverImageUrl": coverImage.asset->url,
  body
}`;

export async function getPost(slug: string): Promise<Post | null> {
  "use cache";
  cacheLife("max");
  cacheTag("posts", `post:${slug}`);

  return client.fetch<Post | null>(POST_QUERY, { slug });
}

export async function getPostSlugs(): Promise<string[]> {
  "use cache";
  cacheLife("max");
  cacheTag("posts");

  return client.fetch<string[]>(
    `*[_type == "post" && defined(slug.current)].slug.current`,
  );
}

What's worth noticing:

  • GROQ shapes the response. The projection inside the braces renames fields ("slug": slug.current) and follows references (coverImage.asset->url dereferences the image asset to get its URL). The query returns exactly the Post shape, so there's no mapping code.
  • $slug is a parameter, passed separately in the second argument. Never interpolate user input into a GROQ string.
  • apiVersion pins the API behavior to a date. Use a fixed date, not "today", so behavior doesn't change underneath you.
  • useCdn: true reads from Sanity's edge cache, which is fast and cheap. Your Next.js cache sits in front of that anyway.

Sanity stores rich text as Portable Text, a JSON array of blocks. Render it with @portabletext/react:

// src/components/sanity-body.tsx
import { PortableText, type PortableTextBlock } from "@portabletext/react";

export function SanityBody({ value }: { value: PortableTextBlock[] }) {
  return <PortableText value={value} />;
}

Portable Text lets you map each block type and mark to your own components (passed through the components prop), so a custom "callout" block in the editor can render as your design system's callout component.

next-sanity also offers a Live Content API integration and visual editing for click-to-edit previews. They're worth exploring once the basics work, but the plain client above is all you need to ship.

Integrating Contentful

Contentful's Content Delivery API is a REST API, so you can call it with fetch and no SDK:

// src/lib/cms/contentful.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { draftMode } from "next/headers";
import type { Post } from "./types";

const SPACE = process.env.CONTENTFUL_SPACE_ID!;
const ENV = process.env.CONTENTFUL_ENVIRONMENT ?? "master";

type ContentfulAsset = {
  sys: { id: string };
  fields: { file: { url: string } };
};

type ContentfulPostEntry = {
  fields: {
    slug: string;
    title: string;
    excerpt: string;
    publishedAt: string;
    body: unknown;
    coverImage?: { sys: { id: string } };
  };
};

type ContentfulResponse = {
  items: ContentfulPostEntry[];
  includes?: { Asset?: ContentfulAsset[] };
};

async function contentfulFetch(params: Record<string, string>) {
  const { isEnabled: preview } = await draftMode();
  const host = preview ? "preview.contentful.com" : "cdn.contentful.com";
  const token = preview
    ? process.env.CONTENTFUL_PREVIEW_TOKEN
    : process.env.CONTENTFUL_DELIVERY_TOKEN;

  const search = new URLSearchParams({ content_type: "post", ...params });
  const res = await fetch(
    `https://${host}/spaces/${SPACE}/environments/${ENV}/entries?${search}`,
    { headers: { Authorization: `Bearer ${token}` } },
  );

  if (!res.ok) throw new Error(`Contentful request failed: ${res.status}`);
  return (await res.json()) as ContentfulResponse;
}

export async function getPost(slug: string): Promise<Post | null> {
  "use cache";
  cacheLife("max");
  cacheTag("posts", `post:${slug}`);

  const data = await contentfulFetch({ "fields.slug": slug, limit: "1" });
  const entry = data.items[0];
  if (!entry) return null;

  const asset = data.includes?.Asset?.find(
    (a) => a.sys.id === entry.fields.coverImage?.sys.id,
  );

  return {
    slug: entry.fields.slug,
    title: entry.fields.title,
    excerpt: entry.fields.excerpt,
    publishedAt: entry.fields.publishedAt,
    coverImageUrl: asset ? `https:${asset.fields.file.url}` : null,
    body: entry.fields.body,
  };
}

export async function getPostSlugs(): Promise<string[]> {
  "use cache";
  cacheLife("max");
  cacheTag("posts");

  const data = await contentfulFetch({ select: "fields.slug", limit: "1000" });
  return data.items.map((item) => item.fields.slug);
}

Things to know about Contentful:

  • Linked entries and assets come back in includes, not inline. The entry contains a reference (sys.id), and you look up the actual asset in includes.Asset. That's what the find does. The official contentful SDK resolves these links for you if you'd rather not do it by hand.
  • Asset URLs are protocol-relative (//images.ctfassets.net/...), hence the https: prefix.
  • Drafts come from a separate API. The Preview API at preview.contentful.com returns unpublished content and uses a different token. The function picks the host based on Draft Mode, which is allowed inside a "use cache" function; Draft Mode also bypasses the cache so editors always see fresh content.
  • GraphQL is available too, and is often nicer for nested content because you choose exactly which linked fields to include.

Render Contentful rich text with @contentful/rich-text-react-renderer and its documentToReactComponents function, which accepts options to map embedded entries and assets to your components.

Integrating Strapi

Strapi 5 exposes a REST API for each content type. Create an API token in the Strapi admin (read-only is enough for the front end) and call it with fetch:

// src/lib/cms/strapi.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { draftMode } from "next/headers";
import type { Post } from "./types";

const STRAPI_URL = process.env.STRAPI_URL!; // e.g. https://cms.example.com

type StrapiArticle = {
  documentId: string;
  slug: string;
  title: string;
  excerpt: string;
  publishedAt: string;
  body: unknown;
  cover: { url: string } | null;
};

async function strapiFetch<T>(path: string, params: URLSearchParams) {
  const { isEnabled: preview } = await draftMode();
  if (preview) params.set("status", "draft");

  const res = await fetch(`${STRAPI_URL}/api/${path}?${params}`, {
    headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` },
  });

  if (!res.ok) throw new Error(`Strapi request failed: ${res.status}`);
  return (await res.json()) as { data: T };
}

export async function getPost(slug: string): Promise<Post | null> {
  "use cache";
  cacheLife("max");
  cacheTag("posts", `post:${slug}`);

  const params = new URLSearchParams({
    "filters[slug][$eq]": slug,
    "populate[cover][fields][0]": "url",
    "pagination[pageSize]": "1",
  });

  const { data } = await strapiFetch<StrapiArticle[]>("articles", params);
  const article = data[0];
  if (!article) return null;

  return {
    slug: article.slug,
    title: article.title,
    excerpt: article.excerpt,
    publishedAt: article.publishedAt,
    coverImageUrl: article.cover
      ? new URL(article.cover.url, STRAPI_URL).toString()
      : null,
    body: article.body,
  };
}

export async function getPostSlugs(): Promise<string[]> {
  "use cache";
  cacheLife("max");
  cacheTag("posts");

  const params = new URLSearchParams({
    "fields[0]": "slug",
    "pagination[pageSize]": "100",
  });
  const { data } = await strapiFetch<Pick<StrapiArticle, "slug">[]>(
    "articles",
    params,
  );
  return data.map((article) => article.slug);
}

Notes on Strapi:

  • Filters and population are query parameters in a bracket syntax. Many teams use the qs library to build them from objects; URLSearchParams is fine for simple cases.
  • Relations and media aren't included by default. You opt in with populate, and you can limit the fields to keep responses small.
  • Strapi 5 returns flattened objects. Fields sit directly on each item next to a documentId. If you're reading older tutorials for Strapi 4, you'll see an extra attributes wrapper that no longer exists.
  • Drafts are requested with status=draft. Make sure the token you use is allowed to read drafts only in preview, or use a separate token.
  • Media URLs are relative when using local uploads and absolute when using a cloud upload provider. new URL(url, STRAPI_URL) handles both.
  • Pagination: the slug list here is capped at 100 per page. For larger sites, loop over pages using the meta.pagination info in the response.

Rendering the Page

The page doesn't care which adapter it uses. Swap the import and everything else stays the same:

// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost, getPostSlugs } from "@/lib/cms/sanity"; // or contentful / strapi
import { SanityBody } from "@/components/sanity-body";
import type { PortableTextBlock } from "@portabletext/react";

export async function generateStaticParams() {
  const slugs = await getPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return post ? { title: post.title, description: post.excerpt } : {};
}

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={post.publishedAt}>
        {new Date(post.publishedAt).toLocaleDateString("en-US")}
      </time>
      <SanityBody value={post.body as PortableTextBlock[]} />
    </article>
  );
}

generateStaticParams prerenders existing posts at build time. New posts published later are rendered on their first request and cached from then on. Because getPost is cached and deduplicated, calling it in both generateMetadata and the page doesn't cost a second CMS request. The body renderer is the one CMS-specific piece left in the page; wrap it in a component per CMS.

If you use next/image for cover images, add the CMS image host (cdn.sanity.io, images.ctfassets.net, or your Strapi or storage domain) to images.remotePatterns in next.config.ts.

Revalidating on Publish with Webhooks

All three CMSs can send a webhook when content changes. Point them at one Route Handler:

// src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const secret = request.headers.get("x-revalidate-secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ message: "Invalid secret" }, { status: 401 });
  }

  const payload = (await request.json().catch(() => ({}))) as { slug?: string };

  revalidateTag("posts", "max");
  if (payload.slug) revalidateTag(`post:${payload.slug}`, "max");

  return Response.json({ revalidated: true });
}

How to configure each CMS:

  • Sanity: create a GROQ-powered webhook in the project settings. Filter it to _type == "post", set a projection that sends {"slug": slug.current}, and add the secret as a custom header. Sanity can also sign webhooks; next-sanity includes a parseBody helper that verifies that signature if you prefer it over a shared header.
  • Contentful: add a webhook under Settings, trigger it on Entry publish and unpublish, add the custom header, and use a custom payload that includes the slug field.
  • Strapi: add a webhook in Settings for entry.publish, entry.unpublish, and entry.delete, with the header. Strapi's payload includes the entry, so you can read the slug from it (adjust the handler to read payload.entry.slug).

revalidateTag(tag, "max") marks the cached entries as stale. The next visitor gets the cached page immediately while a fresh version is generated in the background, so publish-to-live typically takes a few seconds and nobody waits on the CMS.

Previewing Drafts

Editors want to see unpublished changes before hitting publish. Next.js Draft Mode handles the switch: a Route Handler validates a secret and sets a cookie, and your data functions read from the draft source while the cookie is present.

// src/app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getPost } from "@/lib/cms/contentful";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");

  if (secret !== process.env.PREVIEW_SECRET || !slug) {
    return new Response("Invalid token", { status: 401 });
  }

  const draft = await draftMode();
  draft.enable();

  const post = await getPost(slug);
  if (!post) return new Response("Post not found", { status: 404 });

  redirect(`/blog/${post.slug}`);
}

Draft Mode is enabled before the lookup so getPost reads from the draft source; otherwise a brand-new, never-published post would return 404. The redirect uses the slug returned by the CMS rather than the raw query parameter, which prevents the endpoint from being used as an open redirect.

Set the preview URL in each CMS to something like https://your-site.com/api/draft?secret=...&slug=...:

  • Contentful: configure it under Content preview; the adapter above switches to the Preview API in Draft Mode.
  • Strapi: configure the preview URL in the admin's preview settings; the adapter adds status=draft.
  • Sanity: use the drafts perspective with a read token in Draft Mode, or the Presentation tool from next-sanity for side-by-side visual editing.

Add a visible banner with an "Exit preview" button so editors know they're seeing drafts; the Next.js Draft Mode guide shows one built with a Server Action that calls draft.disable().

Conclusion

Sanity, Contentful, and Strapi can all power a fast Next.js site. The differences are in who owns what. Sanity gives developers the most control over the editing experience, with schemas in code and a customizable Studio. Contentful gives organizations a managed, governed platform with minimal operations. Strapi gives you the source code and your own infrastructure.

On the Next.js side, the integration is the same for all three: a server-only adapter that returns your own types, cached reads tagged by content, a webhook that calls revalidateTag on publish, and Draft Mode for previews. Build it that way and the CMS becomes a replaceable part rather than something every page depends on.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading