Building a Headless CMS Integration Layer: Content Modeling, Webhook-Driven Rebuilds, and Preview Workflows in Next.js and Astro
A practical guide to integrating a headless CMS with Next.js and Astro, covering content modeling, webhook-driven incremental rebuilds, draft preview modes, caching strategies, and provider migration patterns.
Most headless CMS integrations start the same way: fetch some data in getStaticProps, render a page, ship it. That works until the editorial team asks why a published article takes 20 minutes to appear on the site, why the preview link shows stale content, or why a content type refactor broke half the build. The fetch-and-render approach hides real complexity behind a thin abstraction that eventually cracks.
This guide covers the four layers where headless CMS integrations actually fail in production: content modeling that does not account for references or localization, rebuild pipelines that do not react to CMS events, preview workflows that bypass caching in ways that cause subtle bugs, and the migration problem that teams only think about after they are already committed to a vendor.
Content Modeling: Types, References, and Localization
The mistake most teams make early is treating content types as view models. A BlogPost type that contains an inline author object looks fine until you need to display an author page, cross-reference posts by author, or revalidate all posts when an author’s bio changes. Model content as linked entities from the start.
// types/cms.ts
interface AuthorEntry {
id: string;
name: string;
bio: string;
avatarUrl: string;
slug: string;
}
interface BlogPostEntry {
id: string;
title: string;
slug: string;
body: string; // rich text as serialized JSON or HTML
author: AuthorEntry; // resolved reference, not a raw ID
tags: TagEntry[];
publishedAt: string | null; // null = draft
locale: string;
}
interface TagEntry {
id: string;
label: string;
slug: string;
}
The author field above is a resolved reference. In Contentful terms, this is a linked entry; in Sanity it is a reference with a projection. The important thing is that your application layer always works with resolved types, not raw reference IDs. Raw IDs in your view layer mean you are making N+1 fetches at render time or writing manual join logic.
For localization, the shape matters more than most teams expect. Contentful uses locale-per-field (all locales in one entry response), while Sanity uses a document-per-locale pattern by default. Model your TypeScript types around what your application actually needs, not the CMS’s internal representation:
interface LocalizedBlogPost {
id: string;
slug: string; // locale-specific slug
locale: string;
title: string;
body: string;
alternateLocales: Array<{
locale: string;
slug: string;
}>;
}
The alternateLocales array is what you need for <link rel="alternate" hreflang="..."> tags. Fetch it once per request rather than issuing a separate call to resolve alternate slugs.
Webhook-Driven Rebuilds
Static builds that rebuild on a timer are wasteful and slow. Webhook-driven rebuilds are the correct model, but they require handling three things that documentation usually skips: deduplication, partial invalidation, and failure recovery.
ISR in Next.js with On-Demand Revalidation
Next.js 13+ supports on-demand revalidation via revalidatePath and revalidateTag. This is the right primitive for CMS webhooks. When a CMS entry is published, the webhook fires your revalidation endpoint, which tells Next.js to regenerate only the affected pages on the next request.
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
function verifySignature(body: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
);
}
interface CMSWebhookPayload {
event: "publish" | "unpublish" | "delete";
contentType: string;
entryId: string;
slug?: string;
locale?: string;
}
export async function POST(request: NextRequest): Promise<NextResponse> {
const rawBody = await request.text();
const signature = request.headers.get("x-webhook-signature") ?? "";
if (!verifySignature(rawBody, signature, process.env.CMS_WEBHOOK_SECRET!)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const payload: CMSWebhookPayload = JSON.parse(rawBody);
if (payload.contentType === "blogPost" && payload.slug) {
// Revalidate the specific post page
revalidatePath(`/blog/${payload.slug}`);
// Also revalidate the listing page since post metadata may have changed
revalidatePath("/blog");
// Tag-based revalidation for any component that fetches this entry by ID
revalidateTag(`entry-${payload.entryId}`);
}
if (payload.contentType === "author") {
// All posts by this author need revalidation
revalidateTag(`author-${payload.entryId}`);
}
return NextResponse.json({ revalidated: true, timestamp: Date.now() });
}
The signature verification step is not optional. Unverified webhook endpoints are trivial to abuse, and a flood of spoofed events can exhaust your ISR budget or cause a thundering herd against the CMS API.
Tag-based revalidation is more precise than path-based when a single content entity affects many pages. Tag the fetch call at the data layer:
// lib/cms/blog.ts
import { unstable_cache } from "next/cache";
export const getBlogPost = unstable_cache(
async (slug: string, locale: string): Promise<BlogPostEntry | null> => {
const entry = await cmsClient.getBlogPostBySlug(slug, locale);
return entry ?? null;
},
["blog-post"],
{
tags: (slug, locale) => [`entry-${slug}-${locale}`],
revalidate: 3600, // fallback TTL if no webhook fires
}
);
On-Demand Builds in Astro
Astro does not have ISR in the Next.js sense. With static output mode, the entire site rebuilds. The practical solution for Astro is to trigger a CI/CD pipeline run on CMS publish events, keep the build fast (under 60 seconds for most content sites), and use a CDN with short TTLs as a safety net.
With Astro’s server-side rendering output mode, you can implement per-request revalidation yourself using a caching adapter. The simpler pattern for most teams is a hybrid: SSR for preview/draft routes, static output for production content routes, with a build trigger webhook:
// src/pages/api/rebuild.ts (Astro API route)
import type { APIRoute } from "astro";
import crypto from "crypto";
export const POST: APIRoute = async ({ request }) => {
const body = await request.text();
const sig = request.headers.get("x-webhook-signature") ?? "";
const expected = crypto
.createHmac("sha256", import.meta.env.CMS_WEBHOOK_SECRET)
.update(body)
.digest("hex");
if (sig !== expected) {
return new Response("Unauthorized", { status: 401 });
}
// Trigger Netlify/Vercel/Cloudflare build hook
await fetch(import.meta.env.BUILD_HOOK_URL, { method: "POST" });
return new Response(JSON.stringify({ triggered: true }), {
headers: { "Content-Type": "application/json" },
});
};
For teams using Cloudflare Pages, the build hook approach adds 30-90 seconds of latency between publish and live. If that is unacceptable, move content-heavy routes to SSR with a short Cache-Control: max-age=60, stale-while-revalidate=300 header. The CDN absorbs the load; the origin regenerates in the background.
Preview and Draft Mode
Preview mode is where integrations tend to develop subtle correctness bugs. The core problem: preview requests need to bypass the CDN and the data cache to show unpublished content, while production requests must not accidentally hit the draft API.
Next.js Draft Mode
Next.js App Router has a built-in draftMode() API. The flow is: editor clicks a preview link from the CMS, that link hits your preview enable endpoint, the endpoint sets a cookie, subsequent requests for that session see draft content.
// app/api/preview/enable/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest): Promise<Response> {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
const contentType = searchParams.get("type") ?? "blogPost";
if (secret !== process.env.CMS_PREVIEW_SECRET) {
return new Response("Invalid token", { status: 401 });
}
if (!slug) {
return new Response("Missing slug", { status: 400 });
}
draftMode().enable();
const redirectPath =
contentType === "blogPost" ? `/blog/${slug}` : `/${slug}`;
redirect(redirectPath);
}
// app/api/preview/disable/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(): Promise<Response> {
draftMode().disable();
redirect("/");
}
In your page component, check draft mode to decide which CMS API to call:
// app/blog/[slug]/page.tsx
import { draftMode } from "next/headers";
import { getBlogPost, getDraftBlogPost } from "@/lib/cms/blog";
interface PageProps {
params: { slug: string };
}
export default async function BlogPostPage({ params }: PageProps) {
const { isEnabled } = draftMode();
const post = isEnabled
? await getDraftBlogPost(params.slug)
: await getBlogPost(params.slug, "en");
if (!post) notFound();
return <BlogPostView post={post} isDraft={isEnabled} />;
}
One non-obvious gotcha: getDraftBlogPost must use the CMS’s preview API token, not the delivery token. These are different credentials in Contentful and Sanity. Keep them in separate environment variables and never expose the preview token to the client.
Caching Strategy Between CMS and Frontend
The fetch path from CMS to rendered page crosses several caches: the CMS CDN, your data-fetching layer, Next.js data cache or Astro’s fetch cache, and the CDN in front of your frontend. Each layer has its own TTL, and they can produce different content at the same URL simultaneously.
A practical layered strategy:
| Layer | Tool | TTL | Invalidation |
|---|---|---|---|
| CMS delivery API | Contentful/Sanity CDN | 60s-300s | CMS-controlled |
| Application data cache | Next.js unstable_cache | 3600s | Webhook tag revalidation |
| Page cache | Next.js full-route cache | Until revalidation | revalidatePath on publish |
| Edge CDN | Vercel/Cloudflare | stale-while-revalidate | Automatic on deploy / tag purge |
The most common mistake is setting a long TTL at the application data cache layer without wiring up webhook invalidation. You get a 24-hour stale page that no amount of CMS republishing can fix because the data cache is not listening for invalidation signals.
For Astro with ISR-like behavior on Cloudflare Workers, the pattern is a KV cache with a short TTL and a background revalidation queue:
// src/lib/cms/cached-fetch.ts (Astro + Cloudflare Workers)
interface CacheEntry<T> {
data: T;
cachedAt: number;
ttl: number;
}
export async function cachedCmsFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number,
kv: KVNamespace
): Promise<T> {
const cached = await kv.get<CacheEntry<T>>(key, "json");
const now = Date.now();
if (cached) {
const age = (now - cached.cachedAt) / 1000;
if (age < cached.ttl) {
return cached.data;
}
// Stale: return cached data and revalidate in background
// (Cloudflare Workers waitUntil pattern)
}
const fresh = await fetcher();
await kv.put(
key,
JSON.stringify({ data: fresh, cachedAt: now, ttl: ttlSeconds }),
{ expirationTtl: ttlSeconds * 2 } // keep in KV longer than TTL for SWR
);
return fresh;
}
CMS Provider Migration Patterns
Migrating between CMS providers is painful because every content type, every reference, and every rich text format is vendor-specific. The integration layer you build should insulate the frontend from those differences from day one.
The abstraction worth building is a CMS adapter interface:
// lib/cms/types.ts
export interface CMSAdapter {
getBlogPost(slug: string, locale: string): Promise<BlogPostEntry | null>;
getBlogPostList(locale: string, limit: number): Promise<BlogPostEntry[]>;
getAuthor(id: string): Promise<AuthorEntry | null>;
getDraftBlogPost(slug: string): Promise<BlogPostEntry | null>;
}
// lib/cms/contentful-adapter.ts
import { createClient } from "contentful";
import type { CMSAdapter } from "./types";
export function createContentfulAdapter(
spaceId: string,
accessToken: string,
previewToken: string
): CMSAdapter {
const client = createClient({ space: spaceId, accessToken });
const previewClient = createClient({
space: spaceId,
accessToken: previewToken,
host: "preview.contentful.com",
});
return {
async getBlogPost(slug, locale) {
const response = await client.getEntries({
content_type: "blogPost",
"fields.slug": slug,
locale,
include: 2, // resolve linked entries 2 levels deep
limit: 1,
});
const entry = response.items[0];
if (!entry) return null;
return normalizeContentfulBlogPost(entry);
},
async getDraftBlogPost(slug) {
const response = await previewClient.getEntries({
content_type: "blogPost",
"fields.slug": slug,
include: 2,
limit: 1,
});
const entry = response.items[0];
if (!entry) return null;
return normalizeContentfulBlogPost(entry);
},
// ... getBlogPostList, getAuthor
};
}
When you migrate from Contentful to Sanity (or any other combination), you write a createSanityAdapter that conforms to the same interface. The application code changes nothing. The normalization functions are the migration surface, not the pages.
The tradeoffs of this approach:
| Approach | Flexibility | Maintenance | Type safety | When to use |
|---|---|---|---|---|
| Direct CMS SDK calls in pages | Low | High | Low | Single provider, small site |
| Shared adapter interface | High | Medium | High | Multi-provider or expected migration |
| GraphQL code generation | Medium | Medium | Very high | Contentful/Hygraph with GraphQL API |
| Third-party abstraction (Keystatic, etc.) | Medium | Low | Medium | When vendor lock-in is the primary concern |
For rich text, never store vendor-specific rich text AST in your component layer. Convert it to a portable format at the adapter boundary. Contentful uses documentToReactComponents from @contentful/rich-text-react-renderer; Sanity uses @portabletext/react. Both should produce the same React component tree via your own RichTextRenderer component that accepts a normalized portable text AST.
Production Considerations
A few patterns that surface repeatedly in production CMS integrations:
Webhook fan-out. A single author update can invalidate hundreds of posts. If your revalidation endpoint is synchronous and calls revalidatePath for each post, you will hit Next.js internal limits or time out the webhook request. Queue the invalidation work: respond to the webhook immediately with 200, enqueue the list of affected paths, and process revalidations asynchronously.
Locale and slug collisions. If your CMS allows the same slug in different locales, your cache keys and route parameters must include the locale. A cache keyed only on slug will serve French content to English users after the first write. Use ${locale}:${slug} as the cache key everywhere.
Circular references in content models. Contentful’s include depth parameter (and Sanity’s projection depth) controls how deeply linked entries are resolved. Circular references (Post references Tag, Tag references Post) cause the SDK to either throw or silently truncate the graph. Detect them early with a content model audit before they cause subtle undefined access bugs at runtime.
Environment separation. CMS providers typically offer separate environments (Contentful) or datasets (Sanity) for dev/staging/prod. Wire your environment variables so that local development never touches production content, and staging previews only pull from the staging CMS environment. This sounds obvious but breaks down when preview tokens are shared across environments.
Rate limits. The Contentful Delivery API rate-limits at 78 requests per second on paid plans; the Preview API is lower. Sanity’s CDN-backed API is more forgiving, but the un-CDN-cached API (useCdn: false) used for preview is rate-limited too. If your build fetches content for thousands of pages, batch requests and respect the rate limit headers rather than parallelizing everything.
Closing
The integration layer between a headless CMS and a frontend framework is not just a data-fetching problem. It is a cache invalidation problem, a schema boundary problem, and a vendor coupling problem, all at once. The teams that handle it well build thin adapters with clear normalization boundaries, wire webhook invalidation to cache tags rather than full rebuilds, and keep draft mode separate from production fetch paths at the credential level. The teams that handle it poorly rebuild everything on every content change and spend time debugging stale pages that should have been invalidated three publishes ago.
Start with the adapter interface even if you have one provider today. It costs an hour up front and saves days when the CMS contract comes up for renewal.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.