Інтеграція Contentful з фронтендом (React/Next.js/Gatsby)
Contentful як headless CMS відає контент через JSON API, і завдання інтеграції — не просто «забрати дані», а правильно організувати типізацію, кешування, інкрементальну регенерацію та preview-режим. Підходи для Next.js, Gatsby та чистого React суттєво різняться.
Типізація з Content Types
Ручне написання TypeScript-інтерфейсів для Contentful-моделей — джерело помилок. Використовуємо @contentful/rich-text-types та генерацію типів через cf-content-types-generator:
npx cf-content-types-generator \
--spaceId $CONTENTFUL_SPACE_ID \
--token $CONTENTFUL_MANAGEMENT_TOKEN \
--out src/types/contentful.ts \
--v10 # сумісність з contentful SDK v10+
Результат — строго типізовані інтерфейси для всіх Content Types:
// Автогенерований тип
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, // глибина пов'язаних записів
});
return entries.items;
}
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // ISR: регенерація щогодини
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
Rich Text поле повертає AST-структуру, не HTML. Для рендера використовуємо @contentful/rich-text-react-renderer:
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,
// Окремий токен для preview-середовища в Gatsby Cloud
host: process.env.GATSBY_CONTENTFUL_HOST || 'cdn.contentful.com',
},
}
GraphQL-запит у 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
Для оновлення сторінок без розгортання налаштовуємо Contentful Webhook на 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 });
}
Оптимізація зображень
Contentful Images API підтримує трансформації через URL-параметри. Інтеграція з Next.js Image:
// next.config.ts
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.ctfassets.net' },
{ protocol: 'https', hostname: 'downloads.ctfassets.net' },
],
}
// Компонент
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"
/>
Терміни типової інтеграції
| Завдання | Час |
|---|---|
| Базова налаштування клієнта + типізація | 0.5 дня |
| Вивід списку та сторінок одного Content Type | 1 день |
| Rich Text рендерер з кастомними вузлами | 0.5–1 день |
| ISR + Webhook revalidation | 0.5 дня |
| Preview Mode (Draft Mode) | 0.5 дня |
| Повна інтеграція (5–10 Content Types) | 3–5 днів |







