Lesson 47 of 81
A headless CMS stores content and hands it over as JSON, not HTML — which means the interesting engineering problem isn't the fetch, it's telling your already-built pages the instant an editor hits publish.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A headless CMS (Contentful, Sanity, Payload, Strapi, and similar tools) is a content editor's admin UI plus an API — no templating, no frontend of its own. Your Next.js app owns the actual pages; the CMS is just where non-developers write and publish content, and where your app fetches it from. The "headless" part is the whole pitch: an editor can hit publish without a developer touching code, and the app decides how that content actually renders.
The fetch itself is the easy part — it's a normal fetch call inside a Server Component, same as fetching from any other API. The genuinely hard part is staleness: your pages are cached (statically generated, or fetched with Next.js's fetch cache), so a publish in the CMS doesn't automatically appear on the live site. The fix is the on-demand-revalidation webhook pattern — the CMS calls a Route Handler in your app the instant something is published, and that handler tells Next.js's cache exactly what just went stale.
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const res = await fetch(`https://cms.example.com/api/posts/${slug}`, {
next: { tags: [`post-${slug}`] }, // tag this fetch so a webhook can invalidate exactly this post
});
const post = await res.json();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
</article>
);
}
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
const { slug } = await request.json();
revalidateTag(`post-${slug}`, { expire: 0 }); // immediate: a webhook can't use updateTag, it isn't a Server Action
return NextResponse.json({ revalidated: true, slug });
}
The CMS is configured (in its own admin UI, not in this app's code) to POST to /api/revalidate whenever a post is published — this is the "on-demand-revalidation webhook pattern" by name: a webhook is just an HTTP callback a third-party service makes into your app when something happens on their end. The secret header check matters because this endpoint is public by necessity (the CMS has to be able to reach it from outside your network) — without it, anyone who finds the URL could trigger cache invalidation, which isn't a data breach but is a real denial-of-service angle against your caching.
revalidateTag inside a webhook Route Handler needs the immediate-expiry form. updateTag — the tool for instant read-your-own-writes — only works inside Server Actions; a Route Handler receiving a third-party webhook has to use revalidateTag(tag, { expire: 0 }) instead."cms-content" tag means every publish anywhere invalidates and refetches the entire site's cached content; tagging per-entity (post-${slug}) means a single publish only invalidates what actually changed.This lesson doesn't reintroduce revalidateTag, revalidatePath, or updateTag from scratch — the on-demand revalidation lesson already covers what each does and when to reach for which. What's new here is the trigger: a third-party webhook calling into a Route Handler, which is exactly the "revalidateTag with immediate expiry, because updateTag isn't available outside a Server Action" case that lesson calls out.