Разработка сайта на CMS Contentful
Contentful — одна из первых и наиболее зрелых headless CMS на рынке. Полностью SaaS: хранилище контента, CDN, APIs — в облаке Contentful. Контентная модель создаётся через GUI (Content Model) или через API/CLI. REST API (Content Delivery API, Content Management API) и GraphQL из коробки.
Архитектура
Contentful Studio (Web)
↕ CMA (Content Management API)
Content Lake (Contentful)
↕ CDA (Content Delivery API) / GraphQL
Next.js / Nuxt / Remix / Mobile App
↕ Contentful Image API (CDN)
CDA — публичный API с CDN-кэшем. CMA — для создания/редактирования записей (серверный токен, не для фронтенда). Preview API — для черновиков.
Content Types
Content Types создаются через Contentful GUI: Content Model → Add Content Type. Поля:
- Text, Rich Text, Number, Date, Media, Boolean
- Reference (связь с другим content type)
- Array (список reference или media)
Можно создать через CLI:
npm install -g contentful-cli
contentful login
contentful space export --space-id YOUR_SPACE_ID
# Экспортирует схему и контент в JSON
REST API (Content Delivery API)
# Список записей
GET https://cdn.contentful.com/spaces/{SPACE_ID}/environments/master/entries
?content_type=blogPost
&fields.slug=my-post
&include=3
Authorization: Bearer {CDA_TOKEN}
# Одна запись
GET .../entries/{ENTRY_ID}?include=2
# Ресурсы (медиа)
GET .../assets?order=sys.createdAt&limit=20
SDK для JavaScript
npm install contentful
// lib/contentful.ts
import { createClient, type EntryFieldTypes } from 'contentful'
export const contentfulClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
environment: 'master',
})
// Preview client для черновиков
export const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: 'preview.contentful.com',
})
// TypeScript интерфейсы (можно генерировать через cf-content-types-generator)
export interface BlogPostSkeleton {
contentTypeId: 'blogPost'
fields: {
title: EntryFieldTypes.Text
slug: EntryFieldTypes.Text
content: EntryFieldTypes.RichText
excerpt: EntryFieldTypes.Text
publishedAt: EntryFieldTypes.Date
heroImage: EntryFieldTypes.AssetLink
author: EntryFieldTypes.EntryLink<AuthorSkeleton>
tags: EntryFieldTypes.Array<EntryFieldTypes.Symbol>
}
}
Next.js App Router интеграция
// app/blog/[slug]/page.tsx
import { contentfulClient } from '@/lib/contentful'
import type { BlogPostSkeleton } from '@/lib/contentful'
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS, INLINES } from '@contentful/rich-text-types'
import { notFound } from 'next/navigation'
const richTextOptions = {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node: any) => (
<img
src={`https:${node.data.target.fields.file.url}`}
alt={node.data.target.fields.title}
className="rounded-lg my-6"
/>
),
[INLINES.HYPERLINK]: (node: any, children: React.ReactNode) => (
<a href={node.data.uri} className="text-blue-600 hover:underline">{children}</a>
),
},
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const entries = await contentfulClient.getEntries<BlogPostSkeleton>({
content_type: 'blogPost',
'fields.slug': params.slug,
include: 2,
limit: 1,
})
const post = entries.items[0]
if (!post) notFound()
const { title, content, heroImage, author } = post.fields
return (
<article>
<h1>{title}</h1>
{heroImage && typeof heroImage !== 'string' && (
<img
src={`https:${heroImage.fields.file?.url}`}
alt={typeof heroImage.fields.title === 'string' ? heroImage.fields.title : ''}
/>
)}
<div>{documentToReactComponents(content, richTextOptions)}</div>
</article>
)
}
export async function generateStaticParams() {
const entries = await contentfulClient.getEntries<BlogPostSkeleton>({
content_type: 'blogPost',
select: ['fields.slug'],
limit: 1000,
})
return entries.items.map(entry => ({ slug: entry.fields.slug }))
}
export const revalidate = 3600
GraphQL API
const POSTS_QUERY = `
query GetPosts($limit: Int, $locale: String) {
blogPostCollection(limit: $limit, locale: $locale, order: publishedAt_DESC) {
items {
title
slug
excerpt
publishedAt
heroImage { url title }
author { name }
}
}
}
`
async function fetchGraphQL(query: string, variables = {}) {
const response = await fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
next: { tags: ['contentful'], revalidate: 3600 },
}
)
return response.json()
}
const { data } = await fetchGraphQL(POSTS_QUERY, { limit: 10, locale: 'ru' })
const posts = data.blogPostCollection.items
Вебхук для ISR
В Contentful: Settings → Webhooks → Add Webhook:
- URL:
https://yoursite.com/api/revalidate/contentful - Trigger: Entry Publish, Entry Unpublish, Asset Publish
// app/api/revalidate/contentful/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const secret = req.headers.get('x-contentful-webhook-secret')
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const contentType = body.sys?.contentType?.sys?.id
revalidateTag('contentful')
if (contentType) revalidateTag(contentType)
return Response.json({ revalidated: true })
}
Сравнение с Sanity/Directus/Strapi
| Критерий | Contentful | Sanity | Strapi | Directus |
|---|---|---|---|---|
| Hosting | SaaS only | SaaS only | Self-hosted / Cloud | Self-hosted / Cloud |
| Бесплатный план | 25k records, 2 locale | 10k documents, 3 users | ≤ open source | ≤ open source |
| Rich text | Rich Text (Contentful) | Portable Text (кастом) | Markdown/Draft.js | JSON |
| Real-time | Нет | Да | Нет | Да (WebSocket) |
| Типичная цена | От $300/мес Team | От $15/мес | Бесплатно (self-hosted) | Бесплатно (self-hosted) |
Сроки
Базовый сайт на Contentful с 3–5 content types и Next.js — 1,5–2 недели. Сложный мультиязычный проект с Preview Mode и GraphQL — 3–4 недели.







