Contentful Frontend Integration (React/Next.js/Gatsby)
Contentful as a headless CMS delivers content via JSON API, and the integration task is not just to "fetch data" but to properly organize typing, caching, incremental regeneration, and preview mode. Approaches for Next.js, Gatsby, and plain React differ significantly.
Typing from Content Types
Manually writing TypeScript interfaces for Contentful models is error-prone. Use @contentful/rich-text-types and type generation via cf-content-types-generator:
npx cf-content-types-generator \
--spaceId $CONTENTFUL_SPACE_ID \
--token $CONTENTFUL_MANAGEMENT_TOKEN \
--out src/types/contentful.ts \
--v10 # compatibility with contentful SDK v10+
Result — strictly typed interfaces for all Content Types:
// Auto-generated type
export interface TypeBlogPostFields {
title: EntryFieldTypes.Symbol;
slug: EntryFieldTypes.Symbol;
body: EntryFieldTypes.RichText;
heroImage: EntryFieldTypes.AssetLink;
author: EntryFieldTypes.EntryLink<TypeAuthorSkeleton>;
publishedAt: EntryFieldTypes.Date;
tags: EntryFieldTypes.Array<EntryFieldTypes.Symbol>;
}
export type TypeBlogPostSkeleton = EntrySkeletonType<TypeBlogPostFields, 'blogPost'>;
Next.js App Router: Server Components + ISR
// lib/contentful.ts
import { createClient, type Entry } from 'contentful';
import type { TypeBlogPostSkeleton } from '@/types/contentful';
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN!,
});
export async function getBlogPosts() {
const entries = await client.getEntries<TypeBlogPostSkeleton>({
content_type: 'blogPost',
order: ['-fields.publishedAt'],
include: 2, // depth of related entries
});
return entries.items;
}
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // ISR: regenerate hourly
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map((post) => ({ slug: post.fields.slug }));
}
export default async function BlogPostPage({ params }) {
const post = await getPostBySlug(params.slug);
return <BlogPost post={post} />;
}
Rich Text Rendering
Rich Text field returns AST structure, not HTML. Use @contentful/rich-text-react-renderer for rendering:
import { documentToReactComponents, Options } from '@contentful/rich-text-react-renderer';
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types';
import Image from 'next/image';
const renderOptions: Options = {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const asset = node.data.target;
return (
<Image
src={`https:${asset.fields.file.url}`}
width={asset.fields.file.details.image.width}
height={asset.fields.file.details.image.height}
alt={asset.fields.description || asset.fields.title}
className="rounded-lg my-6"
/>
);
},
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target;
if (entry.sys.contentType.sys.id === 'codeBlock') {
return <CodeBlock code={entry.fields.code} lang={entry.fields.language} />;
}
return null;
},
[INLINES.HYPERLINK]: (node, children) => (
<a href={node.data.uri} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
},
renderMark: {
[MARKS.CODE]: (text) => <code className="bg-muted px-1 rounded">{text}</code>,
},
};
export const RichText = ({ document }) =>
documentToReactComponents(document, renderOptions);
Gatsby: Source Plugin
// gatsby-config.ts
{
resolve: 'gatsby-source-contentful',
options: {
spaceId: process.env.CONTENTFUL_SPACE_ID,
accessToken: process.env.CONTENTFUL_DELIVERY_TOKEN,
enableTags: true,
// Separate token for preview environment in Gatsby Cloud
host: process.env.GATSBY_CONTENTFUL_HOST || 'cdn.contentful.com',
},
}
GraphQL query in Gatsby Page:
query BlogPostQuery($slug: String!) {
contentfulBlogPost(slug: { eq: $slug }) {
title
publishedAt
body {
raw
references {
... on ContentfulAsset {
contentful_id
__typename
url
width
height
description
}
}
}
heroImage {
gatsbyImageData(
width: 1200
placeholder: BLURRED
formats: [AUTO, WEBP, AVIF]
)
}
}
}
Webhook + On-demand ISR
To update pages without deployment, set up Contentful Webhook for Next.js revalidation endpoint:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const secret = request.headers.get('x-contentful-webhook-secret');
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const contentType = body.sys?.contentType?.sys?.id;
const slug = body.fields?.slug?.['en-US'];
if (contentType === 'blogPost' && slug) {
revalidatePath(`/blog/${slug}`);
revalidateTag('blog-posts');
}
return Response.json({ revalidated: true });
}
Image Optimization
Contentful Images API supports transformations via URL parameters. Integration with Next.js Image:
// next.config.ts
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.ctfassets.net' },
{ protocol: 'https', hostname: 'downloads.ctfassets.net' },
],
}
// Component
const contentfulLoader = ({ src, width, quality }) =>
`${src}?w=${width}&q=${quality || 75}&fm=webp`;
<Image
loader={contentfulLoader}
src={`https:${asset.fields.file.url}`}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
/>
Timeline for Typical Integration
| Task | Time |
|---|---|
| Basic client setup + typing | 0.5 days |
| List and pages for one Content Type | 1 day |
| Rich Text renderer with custom nodes | 0.5–1 days |
| ISR + Webhook revalidation | 0.5 days |
| Preview Mode (Draft Mode) | 0.5 days |
| Full integration (5–10 Content Types) | 3–5 days |







