Contentful is a headless CMS with a REST and GraphQL API — createClient({ space, accessToken }) initializes the Content Delivery API client. client.getEntries({ content_type: "blogPost", include: 2 }) fetches entries with linked entries at up to 10 levels deep. client.getEntry(entryId) fetches a single entry. The cf-content-types-generator CLI generates TypeScript interfaces from your Contentful content model. Rich text fields are rendered with documentToReactComponents(field.fields.body, options) where options.renderNode maps BLOCKS to custom React components. Asset fields resolve to {fields: { file: { url, details } }}. The Preview API uses a separate previewAccessToken with host: "preview.contentful.com" for draft content. Next.js ISR with revalidate keeps pages fresh. Webhook handlers call revalidatePath or revalidateTag on content publish. Claude Code generates Contentful client setup, typed entry queries, rich text rendering, Next.js ISR integration, and preview mode patterns.
CLAUDE.md for Contentful
## Contentful Stack
- Version: contentful >= 10.8, @contentful/rich-text-react-renderer >= 15.22
- Client: createClient({ space: SPACE_ID, accessToken: CDA_TOKEN })
- Preview: createClient({ space, accessToken: PREVIEW_TOKEN, host: "preview.contentful.com" })
- Query: client.getEntries<TypedEntry>({ content_type: "post", include: 2, limit: 100 })
- Types: cf-content-types-generator generates TypeFields interfaces from content model
- Rich text: documentToReactComponents(entry.fields.body, { renderNode: { BLOCKS.PARAGRAPH: ... } })
- Asset: https:${entry.fields.image.fields.file.url}?w=800&fm=webp — URL with transforms
- ISR: export const revalidate = 3600 in page.tsx — revalidate every hour
- Webhook: revalidatePath("/blog") in POST /api/revalidate — on publish event
Client Setup
// lib/contentful.ts — typed Contentful client
import { createClient, type EntrySkeletonType, type Entry } from "contentful"
// CDA client (published content)
export const contentfulClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
// Preview client (draft content)
export const contentfulPreviewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: "preview.contentful.com",
})
export function getContentfulClient(preview = false) {
return preview ? contentfulPreviewClient : contentfulClient
}
TypeScript Content Types
// types/contentful.ts — generated by cf-content-types-generator, then customized
import type { Document } from "@contentful/rich-text-types"
// Run: npx cf-content-types-generator -s SPACE_ID -t CDA_TOKEN -o src/types/contentful.ts
export interface BlogPostFields {
title: string
slug: string
summary: string
body: Document // Rich text field
author: AuthorEntry
coverImage: ContentfulAsset
tags: string[]
publishedAt: string
featured: boolean
seoTitle?: string
seoDescription?: string
}
export interface AuthorFields {
name: string
bio: string
avatar: ContentfulAsset
twitter?: string
}
export interface ContentfulAsset {
fields: {
title: string
description?: string
file: {
url: string
contentType: string
details: {
size: number
image?: { width: number; height: number }
}
}
}
}
export interface BlogPostEntry {
sys: { id: string; updatedAt: string; createdAt: string }
fields: BlogPostFields
}
export interface AuthorEntry {
sys: { id: string }
fields: AuthorFields
}
// Asset URL helper with Contentful Image API transforms
export function assetUrl(
asset: ContentfulAsset,
options: { width?: number; height?: number; format?: "webp" | "jpg" | "png"; quality?: number } = {}
): string {
const params = new URLSearchParams()
if (options.width) params.set("w", String(options.width))
if (options.height) params.set("h", String(options.height))
if (options.format) params.set("fm", options.format)
if (options.quality) params.set("q", String(options.quality))
const qs = params.toString()
return `https:${asset.fields.file.url}${qs ? `?${qs}` : ""}`
}
Content Queries
// lib/blog-service.ts — content fetching
import { contentfulClient, getContentfulClient } from "./contentful"
import type { BlogPostEntry, AuthorEntry } from "@/types/contentful"
// Get all published posts (for static generation)
export async function getAllPosts(preview = false): Promise<BlogPostEntry[]> {
const client = getContentfulClient(preview)
const response = await client.getEntries<BlogPostEntry["fields"]>({
content_type: "blogPost",
include: 2, // Resolve linked entries 2 levels deep (author + avatar)
order: ["-fields.publishedAt"],
limit: 1000,
"fields.featured[exists]": true,
})
return response.items as unknown as BlogPostEntry[]
}
// Get single post by slug
export async function getPostBySlug(
slug: string,
preview = false
): Promise<BlogPostEntry | null> {
const client = getContentfulClient(preview)
const response = await client.getEntries<BlogPostEntry["fields"]>({
content_type: "blogPost",
"fields.slug": slug,
include: 3, // Deeper for embedded entries in rich text
limit: 1,
})
return (response.items[0] as unknown as BlogPostEntry) ?? null
}
// Get featured posts
export async function getFeaturedPosts(limit = 3): Promise<BlogPostEntry[]> {
const response = await contentfulClient.getEntries<BlogPostEntry["fields"]>({
content_type: "blogPost",
"fields.featured": true,
order: ["-fields.publishedAt"],
include: 2,
limit,
})
return response.items as unknown as BlogPostEntry[]
}
// Build all slugs for generateStaticParams
export async function getAllPostSlugs(): Promise<string[]> {
const response = await contentfulClient.getEntries<{ slug: string }>({
content_type: "blogPost",
select: ["fields.slug"],
limit: 1000,
})
return response.items.map(item => item.fields.slug)
}
Next.js App Router Pages
// app/blog/[slug]/page.tsx — ISR + rich text rendering
import { documentToReactComponents } from "@contentful/rich-text-react-renderer"
import { BLOCKS, INLINES, MARKS } from "@contentful/rich-text-types"
import { notFound } from "next/navigation"
import Image from "next/image"
import { getPostBySlug, getAllPostSlugs } from "@/lib/blog-service"
import { assetUrl } from "@/types/contentful"
// Revalidate every hour (ISR)
export const revalidate = 3600
export async function generateStaticParams() {
const slugs = await getAllPostSlugs()
return slugs.map(slug => ({ slug }))
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) return {}
return {
title: post.fields.seoTitle ?? post.fields.title,
description: post.fields.seoDescription ?? post.fields.summary,
openGraph: {
images: [assetUrl(post.fields.coverImage, { width: 1200, height: 630, format: "webp" })],
},
}
}
const richTextOptions = {
renderNode: {
[BLOCKS.PARAGRAPH]: (_node: unknown, children: React.ReactNode) => (
<p className="mb-4 leading-relaxed text-foreground">{children}</p>
),
[BLOCKS.HEADING_2]: (_node: unknown, children: React.ReactNode) => (
<h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>
),
[BLOCKS.HEADING_3]: (_node: unknown, children: React.ReactNode) => (
<h3 className="text-xl font-semibold mt-6 mb-3">{children}</h3>
),
[BLOCKS.UL_LIST]: (_node: unknown, children: React.ReactNode) => (
<ul className="list-disc list-inside mb-4 space-y-1">{children}</ul>
),
[BLOCKS.OL_LIST]: (_node: unknown, children: React.ReactNode) => (
<ol className="list-decimal list-inside mb-4 space-y-1">{children}</ol>
),
[BLOCKS.QUOTE]: (_node: unknown, children: React.ReactNode) => (
<blockquote className="border-l-4 border-primary pl-4 italic my-4 text-muted-foreground">
{children}
</blockquote>
),
[BLOCKS.EMBEDDED_ASSET]: (node: any) => {
const { fields } = node.data.target
if (!fields?.file?.contentType?.startsWith("image/")) return null
return (
<figure className="my-6">
<Image
src={`https:${fields.file.url}?w=800&fm=webp`}
alt={fields.title ?? ""}
width={fields.file.details.image?.width ?? 800}
height={fields.file.details.image?.height ?? 450}
className="rounded-lg w-full"
/>
{fields.description && (
<figcaption className="text-center text-sm text-muted-foreground mt-2">
{fields.description}
</figcaption>
)}
</figure>
)
},
[INLINES.HYPERLINK]: (node: any, children: React.ReactNode) => (
<a href={node.data.uri} className="text-primary hover:underline" target="_blank" rel="noopener">
{children}
</a>
),
},
renderMark: {
[MARKS.CODE]: (text: React.ReactNode) => (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">{text}</code>
),
},
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) notFound()
return (
<article className="max-w-2xl mx-auto py-12 px-4">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.fields.title}</h1>
<p className="text-muted-foreground">{post.fields.summary}</p>
<div className="flex items-center gap-3 mt-4">
<Image
src={assetUrl(post.fields.author.fields.avatar, { width: 40, height: 40, format: "webp" })}
alt={post.fields.author.fields.name}
width={40}
height={40}
className="rounded-full"
/>
<div>
<p className="text-sm font-medium">{post.fields.author.fields.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(post.fields.publishedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
<div className="prose-custom">
{documentToReactComponents(post.fields.body, richTextOptions)}
</div>
</article>
)
}
Webhook Revalidation
// app/api/revalidate/route.ts — Contentful webhook handler
import { revalidatePath, revalidateTag } from "next/cache"
import { createHmac } from "crypto"
export async function POST(req: Request) {
// Verify Contentful webhook signature
const signature = req.headers.get("x-contentful-webhook-name")
if (signature !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await req.json()
const contentType = body.sys?.contentType?.sys?.id
switch (contentType) {
case "blogPost":
revalidatePath("/blog")
revalidatePath(`/blog/${body.fields?.slug?.["en-US"]}`)
revalidateTag("blog-posts")
break
case "author":
revalidatePath("/blog")
revalidateTag("authors")
break
default:
revalidatePath("/")
}
return Response.json({ revalidated: true, contentType })
}
For the Sanity alternative when the editing experience, real-time collaboration, and custom studio plugins are priorities — Sanity has a far more customizable Studio with React components, GROQ queries with joins/projections, and live preview without needing a preview API token, see the Sanity Studio guide. For the Payload CMS alternative when self-hosted CMS with a code-first schema that lives in the same repository as the application is needed — Payload generates the admin UI from TypeScript config with no separate CMS account required, see the Payload CMS guide. The Claude Skills 360 bundle includes Contentful skill sets covering typed queries, rich text rendering, and Next.js ISR. Start with the free tier to try CMS integration generation.