Contentful - Kompletny Przewodnik po Enterprise Headless CMS
Czym jest Contentful?
Contentful to wiodący enterprise headless CMS na świecie, założony w 2013 roku w Berlinie przez Sascha Konietzke i Paolo Negri. Firma szybko stała się liderem rynku headless CMS, pozyskując ponad $175 milionów finansowania i obsługując klientów takich jak Spotify, Red Bull, Vodafone, Intercom i IKEA.
Jako headless CMS, Contentful oddziela warstwę zarządzania treścią od prezentacji. Oferuje intuicyjny interfejs dla content edytorów oraz potężne API dla deweloperów. W przeciwieństwie do self-hosted rozwiązań jak Strapi, Contentful to w pełni zarządzana platforma SaaS z globalnym CDN zapewniającym błyskawiczną dostawę treści.
Contentful wyróżnia się content infrastructure podejściem - nie jest tylko CMS-em, ale kompletną platformą do zarządzania i dostarczania treści na dowolną liczbę kanałów: web, mobile, IoT, digital signage, voice assistants i więcej.
Dlaczego Contentful?
Kluczowe zalety Contentful
- Content Infrastructure - Nie tylko CMS, ale kompletna platforma content
- Globalne CDN - 99.99% uptime, błyskawiczna dostawa treści
- Zaawansowany Content Modeling - Elastyczne struktury danych
- Composable Content - Reusable components i references
- Scheduled Publishing - Planowanie publikacji
- Workflows - Procesy zatwierdzania treści
- Multi-locale - Natywne wsparcie wielojęzyczności
- Enterprise Security - SOC 2, GDPR, SSO, RBAC
Contentful vs Inne CMS
| Cecha | Contentful | Strapi | Sanity | Prismic |
|---|---|---|---|---|
| Typ | SaaS | Self-hosted | SaaS | SaaS |
| Cena startowa | Free (25K API calls) | Free | Free | Free |
| Enterprise tier | $489+/mo | Custom | Custom | $500+/mo |
| CDN | Global, built-in | Trzeba dodać | Built-in | Built-in |
| Real-time | Preview API | Webhooks | Native | Webhooks |
| Scheduled publishing | ✅ Tak | Plugin | ✅ Tak | ✅ Tak |
| Content workflows | ✅ Tak | Plugin | Custom | ✅ Tak |
| GraphQL | ✅ Native | Plugin | GROQ | ✅ Native |
| Rich text | Structured JSON | HTML | Portable Text | Slices |
| TypeScript | contentful.js | Natywny | @sanity/client | @prismicio/client |
Kiedy wybrać Contentful?
Contentful jest idealny gdy:
- Potrzebujesz enterprise-grade reliability (99.99% SLA)
- Masz duży zespół content edytorów
- Zależy Ci na scheduled publishing i workflows
- Budujesz multi-channel content delivery
- Potrzebujesz globalnego CDN z edge caching
- Enterprise compliance (SOC 2, HIPAA, GDPR)
Rozważ alternatywy gdy:
- Potrzebujesz self-hostingu → Strapi, Payload CMS
- Budżet jest ograniczony → Strapi (free), Sanity (generous free tier)
- Potrzebujesz real-time collaboration → Sanity
Pierwsze Kroki
Utworzenie konta i Space
- Zarejestruj się na contentful.com
- Utwórz nowy Space (odpowiednik projektu)
- Wybierz template lub zacznij od pustego space
Pobranie API keys
W Contentful dashboard: Settings → API keys
Potrzebujesz:
- Space ID - Identyfikator twojego space
- Content Delivery API token - Dla published content
- Content Preview API token - Dla draft content
- Content Management API token - Dla tworzenia/edycji (opcjonalne)
Instalacja SDK
# JavaScript/TypeScript SDK
npm install contentful
# Rich text renderer dla React
npm install @contentful/rich-text-react-renderer
# TypeScript types generator (opcjonalnie)
npm install contentful-typescript-codegen --save-devPodstawowa konfiguracja
// lib/contentful.ts
import { createClient } from 'contentful'
// Delivery API client (dla published content)
export const contentfulClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
// Preview API client (dla draft content)
export const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: 'preview.contentful.com',
})
// Helper function do wyboru klienta
export function getClient(preview = false) {
return preview ? previewClient : contentfulClient
}# .env.local
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_ACCESS_TOKEN=your_delivery_token
CONTENTFUL_PREVIEW_TOKEN=your_preview_token
CONTENTFUL_MANAGEMENT_TOKEN=your_management_tokenContent Modeling
Tworzenie Content Types
W Contentful dashboard: Content model → Add content type
Content Type definiuje strukturę danych (jak schema w bazie danych).
Przykład: Blog Post
Content Type: Blog Post (blogPost)
├── title (Short text) - required
├── slug (Short text) - unique
├── excerpt (Long text)
├── content (Rich text) - required
├── featuredImage (Media, single)
├── author (Reference → Author)
├── category (Reference → Category)
├── tags (References → Tag, many)
├── publishDate (Date & time)
├── seoMetadata (Reference → SEO)
└── relatedPosts (References → Blog Post, many)Dostępne typy pól
| Typ | Opis | Use Case |
|---|---|---|
| Short text | Tekst do 256 znaków | Tytuły, slugi, tagi |
| Long text | Tekst bez limitu | Opisy, excerpts |
| Rich text | Strukturalny JSON | Content z formatowaniem |
| Integer | Liczba całkowita | Kolejność, ilości |
| Decimal | Liczba zmiennoprzecinkowa | Ceny, rating |
| Date & time | Data i czas | Publish date, events |
| Location | Współrzędne GPS | Mapy, lokalizacje |
| Boolean | True/false | Flagi, toggles |
| JSON object | Dowolny JSON | Custom data |
| Media | Pliki, obrazy | Assets |
| Reference | Powiązanie z innym entry | Relacje |
Validation rules
Pole: slug
├── Required: true
├── Unique: true
├── Match pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$
└── Help text: "URL-friendly slug (tylko małe litery, cyfry i myślniki)"
Pole: excerpt
├── Required: false
├── Size: max 300 characters
└── Help text: "Krótki opis do 300 znaków"
Pole: featuredImage
├── Required: true
├── Accept only: Images
├── Image dimensions: min 800x600
└── File size: max 5MBComposable Content z komponentami
Contentful pozwala tworzyć reusable komponenty przez embedded entries w Rich Text lub przez References.
Content Type: Page (page)
├── title (Short text)
├── slug (Short text)
├── sections (References → many)
│ ├── Hero Section
│ ├── Feature Grid
│ ├── Testimonials
│ ├── CTA Block
│ └── FAQ Section
└── seo (Reference → SEO)
Content Type: Hero Section (heroSection)
├── headline (Short text)
├── subheadline (Long text)
├── backgroundImage (Media)
├── ctaText (Short text)
├── ctaLink (Short text)
└── alignment (Short text, dropdown)Content Delivery API
Pobieranie entries
import { contentfulClient } from '@/lib/contentful'
import type { Entry, EntryCollection } from 'contentful'
// Pobierz wszystkie blog posts
async function getBlogPosts() {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
limit: 10,
})
return entries.items
}
// Pobierz pojedynczy post by slug
async function getBlogPostBySlug(slug: string) {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
})
return entries.items[0] || null
}
// Pobierz z relacjami (include depth)
async function getBlogPostWithRelations(slug: string) {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
include: 3, // Głębokość resolving relacji (max 10)
limit: 1,
})
return entries.items[0] || null
}Filtrowanie i Query operators
// Equality
const techPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.category.sys.id': 'categoryId123',
})
// Not equal
const nonFeatured = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.featured[ne]': true,
})
// In array
const selectedCategories = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.category.sys.id[in]': 'cat1,cat2,cat3',
})
// Exists
const withImage = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.featuredImage[exists]': true,
})
// Range (numbers, dates)
const recentPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.publishDate[gte]': '2024-01-01',
'fields.publishDate[lte]': '2024-12-31',
})
// Full-text search
const searchResults = await contentfulClient.getEntries({
content_type: 'blogPost',
query: 'javascript react', // Szuka w wszystkich text fields
})
// Full-text w konkretnym polu
const titleSearch = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.title[match]': 'Next.js',
})
// Location (near coordinates)
const nearbyEvents = await contentfulClient.getEntries({
content_type: 'event',
'fields.location[near]': '52.52,13.405', // lat,lon
'fields.location[within]': '52.0,13.0,53.0,14.0', // bounding box
})Sortowanie i paginacja
// Sortowanie
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate', 'fields.title'], // - dla descending
})
// Paginacja
const PAGE_SIZE = 10
async function getPaginatedPosts(page: number) {
const skip = (page - 1) * PAGE_SIZE
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
skip,
limit: PAGE_SIZE,
})
return {
items: entries.items,
total: entries.total,
currentPage: page,
totalPages: Math.ceil(entries.total / PAGE_SIZE),
hasNextPage: skip + PAGE_SIZE < entries.total,
hasPrevPage: page > 1,
}
}
// Selekcja pól (zmniejszenie payload)
const minimalPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
select: ['fields.title', 'fields.slug', 'fields.excerpt'],
})Struktura odpowiedzi
interface ContentfulResponse<T> {
sys: {
type: 'Array'
}
total: number
skip: number
limit: number
items: Entry<T>[]
includes?: {
Entry?: Entry<any>[]
Asset?: Asset[]
}
}
interface Entry<T> {
sys: {
id: string
type: 'Entry'
contentType: {
sys: {
id: string
}
}
createdAt: string
updatedAt: string
revision: number
locale: string
}
fields: T
metadata: {
tags: Tag[]
}
}
// Przykład użycia
interface BlogPostFields {
title: string
slug: string
content: Document // Rich text
author: Entry<AuthorFields>
publishDate: string
}
const post = entries.items[0] as Entry<BlogPostFields>
console.log(post.fields.title)
console.log(post.sys.id)GraphQL API
Contentful oferuje również GraphQL API dla bardziej złożonych queries.
Endpoint
https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/environments/{ENVIRONMENT}Przykładowe queries
# Pobierz blog posts
query GetBlogPosts($limit: Int, $skip: Int) {
blogPostCollection(limit: $limit, skip: $skip, order: publishDate_DESC) {
total
items {
sys {
id
}
title
slug
excerpt
publishDate
featuredImage {
url
title
width
height
}
author {
name
avatar {
url
}
}
}
}
}
# Pobierz single post
query GetBlogPost($slug: String!) {
blogPostCollection(where: { slug: $slug }, limit: 1) {
items {
title
slug
content {
json
links {
entries {
inline {
sys {
id
}
... on CodeBlock {
language
code
}
}
block {
sys {
id
}
... on VideoEmbed {
title
embedUrl
}
}
}
assets {
block {
sys {
id
}
url
title
width
height
}
}
}
}
author {
name
bio
}
category {
name
slug
}
tagsCollection {
items {
name
slug
}
}
}
}
}GraphQL client setup
// lib/contentful-graphql.ts
import { GraphQLClient, gql } from 'graphql-request'
const endpoint = `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`
export const graphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
},
})
// Dla preview
export const previewGraphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.CONTENTFUL_PREVIEW_TOKEN}`,
},
})
// Query helper
export async function fetchGraphQL<T>(
query: string,
variables?: Record<string, any>,
preview = false
): Promise<T> {
const client = preview ? previewGraphQLClient : graphQLClient
return client.request<T>(query, variables)
}Rich Text Rendering
Contentful Rich Text to strukturalny JSON, nie HTML. Wymaga renderera.
Instalacja
npm install @contentful/rich-text-react-renderer @contentful/rich-text-typesPodstawowe renderowanie
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import type { Document } from '@contentful/rich-text-types'
interface BlogPostProps {
content: Document
}
function BlogPost({ content }: BlogPostProps) {
return (
<article className="prose prose-lg max-w-none">
{documentToReactComponents(content)}
</article>
)
}Custom renderers
import { documentToReactComponents, Options } from '@contentful/rich-text-react-renderer'
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types'
import Image from 'next/image'
import Link from 'next/link'
const renderOptions: Options = {
renderMark: {
[MARKS.BOLD]: (text) => <strong className="font-bold">{text}</strong>,
[MARKS.ITALIC]: (text) => <em className="italic">{text}</em>,
[MARKS.CODE]: (text) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{text}
</code>
),
},
renderNode: {
// Headings
[BLOCKS.HEADING_1]: (node, children) => (
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
),
[BLOCKS.HEADING_2]: (node, children) => (
<h2 className="text-3xl font-bold mt-6 mb-3">{children}</h2>
),
[BLOCKS.HEADING_3]: (node, children) => (
<h3 className="text-2xl font-semibold mt-4 mb-2">{children}</h3>
),
// Paragraphs
[BLOCKS.PARAGRAPH]: (node, children) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
// Lists
[BLOCKS.UL_LIST]: (node, children) => (
<ul className="list-disc list-inside mb-4 space-y-2">{children}</ul>
),
[BLOCKS.OL_LIST]: (node, children) => (
<ol className="list-decimal list-inside mb-4 space-y-2">{children}</ol>
),
// Quotes
[BLOCKS.QUOTE]: (node, children) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-4">
{children}
</blockquote>
),
// Horizontal rule
[BLOCKS.HR]: () => <hr className="my-8 border-gray-200" />,
// Embedded assets (images)
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const { title, description, file } = node.data.target.fields
const { url, details } = file
return (
<figure className="my-8">
<Image
src={`https:${url}`}
alt={description || title}
width={details.image.width}
height={details.image.height}
className="rounded-lg"
/>
{description && (
<figcaption className="text-center text-gray-500 mt-2 text-sm">
{description}
</figcaption>
)}
</figure>
)
},
// Embedded entries (custom components)
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
switch (contentType) {
case 'codeBlock':
return (
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4">
<code className={`language-${entry.fields.language}`}>
{entry.fields.code}
</code>
</pre>
)
case 'videoEmbed':
return (
<div className="aspect-video my-8">
<iframe
src={entry.fields.embedUrl}
title={entry.fields.title}
className="w-full h-full rounded-lg"
allowFullScreen
/>
</div>
)
case 'callout':
return (
<div className={`p-4 rounded-lg my-4 ${
entry.fields.type === 'warning' ? 'bg-yellow-50 border-yellow-200' :
entry.fields.type === 'error' ? 'bg-red-50 border-red-200' :
'bg-blue-50 border-blue-200'
} border`}>
<p className="font-medium">{entry.fields.title}</p>
<p>{entry.fields.message}</p>
</div>
)
default:
return null
}
},
// Inline entries
[INLINES.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
if (contentType === 'inlineCode') {
return (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm">
{entry.fields.code}
</code>
)
}
return null
},
// Hyperlinks
[INLINES.HYPERLINK]: (node, children) => (
<a
href={node.data.uri}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{children}
</a>
),
// Entry hyperlinks (internal links)
[INLINES.ENTRY_HYPERLINK]: (node, children) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
let href = '/'
if (contentType === 'blogPost') {
href = `/blog/${entry.fields.slug}`
} else if (contentType === 'page') {
href = `/${entry.fields.slug}`
}
return (
<Link href={href} className="text-blue-600 hover:underline">
{children}
</Link>
)
},
// Asset hyperlinks (file downloads)
[INLINES.ASSET_HYPERLINK]: (node, children) => {
const asset = node.data.target
return (
<a
href={`https:${asset.fields.file.url}`}
download
className="text-blue-600 hover:underline"
>
{children} (📥 Download)
</a>
)
},
},
}
// Component usage
function RichTextContent({ content }: { content: Document }) {
return (
<div className="prose prose-lg max-w-none">
{documentToReactComponents(content, renderOptions)}
</div>
)
}TypeScript Integration
Generowanie typów
# Instalacja
npm install contentful-typescript-codegen --save-dev
# Generowanie (wymaga Management API token)
npx contentful-typescript-codegen \
--spaceId YOUR_SPACE_ID \
--environment master \
--output src/types/contentful.d.tsKonfiguracja (alternatywa z cf-content-types-generator)
npm install cf-content-types-generator --save-dev// package.json
{
"scripts": {
"generate:types": "cf-content-types-generator --spaceId $CONTENTFUL_SPACE_ID --token $CONTENTFUL_MANAGEMENT_TOKEN -o src/types/contentful.ts"
}
}Przykład wygenerowanych typów
// src/types/contentful.ts
import type { Entry, Asset } from 'contentful'
export interface IBlogPostFields {
title: string
slug: string
excerpt?: string
content: Document
featuredImage?: Asset
author: Entry<IAuthorFields>
category?: Entry<ICategoryFields>
tags?: Entry<ITagFields>[]
publishDate: string
}
export interface IAuthorFields {
name: string
bio?: string
avatar?: Asset
socialLinks?: ISocialLink[]
}
export interface ICategoryFields {
name: string
slug: string
description?: string
}
export type IBlogPost = Entry<IBlogPostFields>
export type IAuthor = Entry<IAuthorFields>
export type ICategory = Entry<ICategoryFields>Typed client
// lib/contentful.ts
import { createClient, Entry } from 'contentful'
import type { IBlogPost, IBlogPostFields } from '@/types/contentful'
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
export async function getBlogPosts(): Promise<IBlogPost[]> {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
order: ['-fields.publishDate'],
})
return entries.items
}
export async function getBlogPostBySlug(slug: string): Promise<IBlogPost | null> {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
include: 3,
})
return entries.items[0] || null
}Preview Mode i Draft Content
Setup Preview API
// lib/contentful.ts
import { createClient } from 'contentful'
export function getContentfulClient(preview = false) {
return createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: preview
? process.env.CONTENTFUL_PREVIEW_TOKEN!
: process.env.CONTENTFUL_ACCESS_TOKEN!,
host: preview ? 'preview.contentful.com' : 'cdn.contentful.com',
})
}Next.js Draft Mode
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
// Verify secret
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
// Enable Draft Mode
draftMode().enable()
// Redirect to the path
redirect(`/blog/${slug}`)
}
// app/api/disable-draft/route.ts
export async function GET() {
draftMode().disable()
redirect('/')
}// app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { getContentfulClient } from '@/lib/contentful'
export default async function BlogPost({ params }: { params: { slug: string } }) {
const { isEnabled: preview } = draftMode()
const client = getContentfulClient(preview)
const post = await client.getEntries({
content_type: 'blogPost',
'fields.slug': params.slug,
limit: 1,
})
if (!post.items[0]) {
notFound()
}
return (
<>
{preview && (
<div className="bg-yellow-100 p-4 text-center">
Preview Mode -{' '}
<a href="/api/disable-draft" className="underline">
Exit Preview
</a>
</div>
)}
<article>
<h1>{post.items[0].fields.title}</h1>
{/* ... */}
</article>
</>
)
}Webhooks i Revalidation
Konfiguracja webhooks w Contentful
Settings → Webhooks → Add Webhook
URL: https://your-site.com/api/revalidate
Triggers: Entry - Publish, Unpublish, Delete
Headers:
x-contentful-webhook-secret: your-secret-keyNext.js Revalidation endpoint
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
interface ContentfulWebhookPayload {
sys: {
id: string
type: string
contentType?: {
sys: {
id: string
}
}
}
fields?: {
slug?: {
'en-US'?: string
}
}
}
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-contentful-webhook-secret')
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 })
}
try {
const payload: ContentfulWebhookPayload = await request.json()
const contentType = payload.sys.contentType?.sys.id
const slug = payload.fields?.slug?.['en-US']
// Revalidate based on content type
switch (contentType) {
case 'blogPost':
revalidateTag('blog-posts')
if (slug) {
revalidatePath(`/blog/${slug}`)
}
revalidatePath('/blog')
break
case 'page':
revalidateTag('pages')
if (slug) {
revalidatePath(`/${slug}`)
}
break
case 'navigation':
revalidateTag('navigation')
revalidatePath('/', 'layout')
break
default:
// Revalidate everything for unknown content types
revalidatePath('/', 'layout')
}
return Response.json({
revalidated: true,
contentType,
slug,
})
} catch (error) {
return Response.json({ message: 'Error revalidating' }, { status: 500 })
}
}Cache tags w Next.js
// lib/contentful.ts
import { unstable_cache } from 'next/cache'
export const getBlogPosts = unstable_cache(
async () => {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
})
return entries.items
},
['blog-posts'],
{
tags: ['blog-posts'],
revalidate: 3600, // Fallback: 1 hour
}
)
export const getBlogPostBySlug = unstable_cache(
async (slug: string) => {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
include: 3,
})
return entries.items[0] || null
},
['blog-post'],
{
tags: ['blog-posts'],
revalidate: 3600,
}
)Lokalizacja (Multi-locale)
Konfiguracja locales
Settings → Locales → Add locale
Default: en-US (English)
Additional: pl (Polish), de (German)Pobieranie zlokalizowanej treści
// Pobierz w konkretnym locale
const polishPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
locale: 'pl',
})
// Pobierz wszystkie locales naraz
const allLocales = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': 'my-post',
locale: '*', // Wszystkie locales
})
// Struktura odpowiedzi z locale: '*'
{
fields: {
title: {
'en-US': 'My Post',
'pl': 'Mój Post',
'de': 'Mein Beitrag'
}
}
}Fallback chain
// Settings → Locales → Fallback
// Konfiguracja:
// pl → en-US (jeśli brak tłumaczenia, użyj angielskiego)
// de → en-US
// W kodzie zawsze dostaniesz wartość (z fallbacka jeśli brak tłumaczenia)Next.js i18n integration
// app/[locale]/blog/[slug]/page.tsx
import { getContentfulClient } from '@/lib/contentful'
interface PageProps {
params: {
locale: string
slug: string
}
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = params
const client = getContentfulClient()
const entries = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
locale: locale,
include: 3,
})
const post = entries.items[0]
// ...
}
// Generowanie statycznych paths dla wszystkich locales
export async function generateStaticParams() {
const client = getContentfulClient()
const locales = ['en', 'pl', 'de']
const entries = await client.getEntries({
content_type: 'blogPost',
select: ['fields.slug'],
})
const params = []
for (const entry of entries.items) {
for (const locale of locales) {
params.push({
locale,
slug: entry.fields.slug,
})
}
}
return params
}Content Management API
Do programowego tworzenia i edycji treści.
npm install contentful-managementimport { createClient } from 'contentful-management'
const managementClient = createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
})
// Pobierz space i environment
const space = await managementClient.getSpace(process.env.CONTENTFUL_SPACE_ID!)
const environment = await space.getEnvironment('master')
// Utwórz nowy entry
const entry = await environment.createEntry('blogPost', {
fields: {
title: {
'en-US': 'New Blog Post',
},
slug: {
'en-US': 'new-blog-post',
},
content: {
'en-US': {
nodeType: 'document',
data: {},
content: [
{
nodeType: 'paragraph',
data: {},
content: [
{
nodeType: 'text',
value: 'This is the content.',
marks: [],
data: {},
},
],
},
],
},
},
},
})
// Opublikuj entry
await entry.publish()
// Aktualizuj entry
entry.fields.title['en-US'] = 'Updated Title'
const updatedEntry = await entry.update()
await updatedEntry.publish()
// Upload asset
const asset = await environment.createAssetFromFiles({
fields: {
title: {
'en-US': 'My Image',
},
file: {
'en-US': {
contentType: 'image/png',
fileName: 'image.png',
file: fs.readFileSync('./image.png'),
},
},
},
})
await asset.processForAllLocales()
await asset.publish()Contentful Apps
Instalowanie Apps
App Framework pozwala rozszerzać Contentful o custom funkcjonalności.
Settings → Apps → Marketplace
Popularne apps:
- AI Content Generator
- Image focal point
- Compose (page builder)
- Launch (scheduled publishing)
- WorkflowsCustom App development
npx create-contentful-app my-app
cd my-app
npm start// src/locations/Field.tsx
import { FieldExtensionSDK } from '@contentful/app-sdk'
import { useSDK } from '@contentful/react-apps-toolkit'
const Field = () => {
const sdk = useSDK<FieldExtensionSDK>()
const value = sdk.field.getValue()
const handleChange = (newValue: string) => {
sdk.field.setValue(newValue)
}
return (
<div>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
/>
<p>Characters: {(value || '').length}</p>
</div>
)
}
export default FieldCennik
Community (Free)
| Cecha | Limit |
|---|---|
| API calls | 25,000/mo |
| Records | 5,000 |
| Users | 5 |
| Locales | 2 |
| Environments | 1 |
| Roles | 2 |
| Content Types | 25 |
Team
| Cecha | Limit | Cena |
|---|---|---|
| API calls | 500,000/mo | $300/mo |
| Records | 50,000 | |
| Users | 15 | |
| Locales | 5 | |
| Environments | 3 | |
| Roles | Custom |
Enterprise
| Cecha | Limit | Cena |
|---|---|---|
| API calls | Custom | Custom |
| SSO | ✅ | |
| SLA | 99.99% | |
| Support | Dedicated | |
| Compliance | SOC 2, HIPAA |
FAQ - Często Zadawane Pytania
Contentful vs Strapi - co wybrać?
Contentful to managed SaaS z globalnym CDN i enterprise features. Strapi to self-hosted open source. Wybierz Contentful gdy potrzebujesz enterprise reliability, scheduled publishing, workflows. Wybierz Strapi gdy chcesz full control i zero miesięcznych kosztów.
Jak działa Contentful CDN?
Contentful używa globalnej sieci CDN (Fastly) z edge locations na całym świecie. Content jest cache'owany blisko użytkowników, co zapewnia <50ms response time. Cache jest automatycznie invalidowany przy publikacji.
Czy mogę użyć Contentful z Next.js App Router?
Tak! Contentful świetnie integruje się z Next.js 13+. Używaj Server Components, Route Handlers dla webhooks, i Draft Mode dla preview. Pamiętaj o cache revalidation przez webhooks.
Jak migrować dane do Contentful?
Użyj contentful-migration CLI lub Management API. Możesz też eksportować/importować przez contentful-cli. Dla dużych migracji rozważ Contentful Migration Tool.
Ile kosztuje przekroczenie limitów?
Na planach płatnych, dodatkowe API calls kosztują ~$10 za 100,000 calls. Dodatkowe records ~$5 za 1,000. Lepiej monitorować usage i upgradować plan w razie potrzeby.
Czy Contentful obsługuje wersjonowanie?
Tak, każdy entry ma historię wersji. Możesz też używać Environments do staging/production workflow i snapshots do backupów.
Podsumowanie
Contentful to enterprise-grade headless CMS idealny dla dużych zespołów i organizacji potrzebujących:
- 99.99% uptime SLA z globalnym CDN
- Zaawansowane workflows z scheduled publishing
- Enterprise security (SOC 2, HIPAA, GDPR)
- Composable content architecture
- Multi-locale z fallback chains
- Powerful APIs (REST, GraphQL, Management)
Jeśli budujesz content-driven aplikację na skalę enterprise i potrzebujesz niezawodności, Contentful jest bezpiecznym wyborem używanym przez największe firmy na świecie.
Contentful - a complete guide to enterprise headless CMS
What is Contentful?
Contentful is the leading enterprise headless CMS in the world, founded in 2013 in Berlin by Sascha Konietzke and Paolo Negri. The company quickly became a market leader in the headless CMS space, raising over $175 million in funding and serving clients such as Spotify, Red Bull, Vodafone, Intercom, and IKEA.
As a headless CMS, Contentful separates the content management layer from presentation. It offers an intuitive interface for content editors and powerful APIs for developers. Unlike self-hosted solutions like Strapi, Contentful is a fully managed SaaS platform with a global CDN ensuring lightning-fast content delivery.
Contentful stands out with its content infrastructure approach - it is not just a CMS, but a complete platform for managing and delivering content to any number of channels: web, mobile, IoT, digital signage, voice assistants, and more.
Why Contentful?
Key advantages of Contentful
- Content Infrastructure - Not just a CMS, but a complete content platform
- Global CDN - 99.99% uptime, lightning-fast content delivery
- Advanced Content Modeling - Flexible data structures
- Composable Content - Reusable components and references
- Scheduled Publishing - Plan content releases in advance
- Workflows - Content approval processes
- Multi-locale - Native internationalization support
- Enterprise Security - SOC 2, GDPR, SSO, RBAC
Contentful vs other CMS
| Feature | Contentful | Strapi | Sanity | Prismic |
|---|---|---|---|---|
| Type | SaaS | Self-hosted | SaaS | SaaS |
| Starting price | Free (25K API calls) | Free | Free | Free |
| Enterprise tier | $489+/mo | Custom | Custom | $500+/mo |
| CDN | Global, built-in | Must add separately | Built-in | Built-in |
| Real-time | Preview API | Webhooks | Native | Webhooks |
| Scheduled publishing | ✅ Yes | Plugin | ✅ Yes | ✅ Yes |
| Content workflows | ✅ Yes | Plugin | Custom | ✅ Yes |
| GraphQL | ✅ Native | Plugin | GROQ | ✅ Native |
| Rich text | Structured JSON | HTML | Portable Text | Slices |
| TypeScript | contentful.js | Native | @sanity/client | @prismicio/client |
When to choose Contentful?
Contentful is ideal when:
- You need enterprise-grade reliability (99.99% SLA)
- You have a large team of content editors
- You care about scheduled publishing and workflows
- You are building multi-channel content delivery
- You need a global CDN with edge caching
- Enterprise compliance (SOC 2, HIPAA, GDPR)
Consider alternatives when:
- You need self-hosting → Strapi, Payload CMS
- Your budget is limited → Strapi (free), Sanity (generous free tier)
- You need real-time collaboration → Sanity
Getting started
Creating an account and Space
- Sign up at contentful.com
- Create a new Space (equivalent to a project)
- Choose a template or start with an empty space
Retrieving API keys
In the Contentful dashboard: Settings → API keys
You will need:
- Space ID - Your space identifier
- Content Delivery API token - For published content
- Content Preview API token - For draft content
- Content Management API token - For creating/editing (optional)
Installing the SDK
# JavaScript/TypeScript SDK
npm install contentful
# Rich text renderer for React
npm install @contentful/rich-text-react-renderer
# TypeScript types generator (optional)
npm install contentful-typescript-codegen --save-devBasic configuration
// lib/contentful.ts
import { createClient } from 'contentful'
// Delivery API client (for published content)
export const contentfulClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
// Preview API client (for draft content)
export const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: 'preview.contentful.com',
})
// Helper function to choose the client
export function getClient(preview = false) {
return preview ? previewClient : contentfulClient
}# .env.local
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_ACCESS_TOKEN=your_delivery_token
CONTENTFUL_PREVIEW_TOKEN=your_preview_token
CONTENTFUL_MANAGEMENT_TOKEN=your_management_tokenContent Modeling
Creating Content Types
In the Contentful dashboard: Content model → Add content type
A Content Type defines the data structure (like a schema in a database).
Example: Blog Post
Content Type: Blog Post (blogPost)
├── title (Short text) - required
├── slug (Short text) - unique
├── excerpt (Long text)
├── content (Rich text) - required
├── featuredImage (Media, single)
├── author (Reference → Author)
├── category (Reference → Category)
├── tags (References → Tag, many)
├── publishDate (Date & time)
├── seoMetadata (Reference → SEO)
└── relatedPosts (References → Blog Post, many)Available field types
| Type | Description | Use case |
|---|---|---|
| Short text | Text up to 256 characters | Titles, slugs, tags |
| Long text | Text with no limit | Descriptions, excerpts |
| Rich text | Structured JSON | Formatted content |
| Integer | Whole number | Ordering, quantities |
| Decimal | Floating-point number | Prices, ratings |
| Date & time | Date and time | Publish date, events |
| Location | GPS coordinates | Maps, locations |
| Boolean | True/false | Flags, toggles |
| JSON object | Arbitrary JSON | Custom data |
| Media | Files, images | Assets |
| Reference | Link to another entry | Relations |
Validation rules
Field: slug
├── Required: true
├── Unique: true
├── Match pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$
└── Help text: "URL-friendly slug (lowercase letters, digits, and hyphens only)"
Field: excerpt
├── Required: false
├── Size: max 300 characters
└── Help text: "Short description up to 300 characters"
Field: featuredImage
├── Required: true
├── Accept only: Images
├── Image dimensions: min 800x600
└── File size: max 5MBComposable Content with components
Contentful allows you to create reusable components through embedded entries in Rich Text or through References.
Content Type: Page (page)
├── title (Short text)
├── slug (Short text)
├── sections (References → many)
│ ├── Hero Section
│ ├── Feature Grid
│ ├── Testimonials
│ ├── CTA Block
│ └── FAQ Section
└── seo (Reference → SEO)
Content Type: Hero Section (heroSection)
├── headline (Short text)
├── subheadline (Long text)
├── backgroundImage (Media)
├── ctaText (Short text)
├── ctaLink (Short text)
└── alignment (Short text, dropdown)Content Delivery API
Fetching entries
import { contentfulClient } from '@/lib/contentful'
import type { Entry, EntryCollection } from 'contentful'
// Fetch all blog posts
async function getBlogPosts() {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
limit: 10,
})
return entries.items
}
// Fetch a single post by slug
async function getBlogPostBySlug(slug: string) {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
})
return entries.items[0] || null
}
// Fetch with relations (include depth)
async function getBlogPostWithRelations(slug: string) {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
include: 3, // Depth of relation resolving (max 10)
limit: 1,
})
return entries.items[0] || null
}Filtering and query operators
// Equality
const techPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.category.sys.id': 'categoryId123',
})
// Not equal
const nonFeatured = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.featured[ne]': true,
})
// In array
const selectedCategories = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.category.sys.id[in]': 'cat1,cat2,cat3',
})
// Exists
const withImage = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.featuredImage[exists]': true,
})
// Range (numbers, dates)
const recentPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.publishDate[gte]': '2024-01-01',
'fields.publishDate[lte]': '2024-12-31',
})
// Full-text search
const searchResults = await contentfulClient.getEntries({
content_type: 'blogPost',
query: 'javascript react', // Searches across all text fields
})
// Full-text in a specific field
const titleSearch = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.title[match]': 'Next.js',
})
// Location (near coordinates)
const nearbyEvents = await contentfulClient.getEntries({
content_type: 'event',
'fields.location[near]': '52.52,13.405', // lat,lon
'fields.location[within]': '52.0,13.0,53.0,14.0', // bounding box
})Sorting and pagination
// Sorting
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate', 'fields.title'], // - for descending
})
// Pagination
const PAGE_SIZE = 10
async function getPaginatedPosts(page: number) {
const skip = (page - 1) * PAGE_SIZE
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
skip,
limit: PAGE_SIZE,
})
return {
items: entries.items,
total: entries.total,
currentPage: page,
totalPages: Math.ceil(entries.total / PAGE_SIZE),
hasNextPage: skip + PAGE_SIZE < entries.total,
hasPrevPage: page > 1,
}
}
// Field selection (reducing payload size)
const minimalPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
select: ['fields.title', 'fields.slug', 'fields.excerpt'],
})Response structure
interface ContentfulResponse<T> {
sys: {
type: 'Array'
}
total: number
skip: number
limit: number
items: Entry<T>[]
includes?: {
Entry?: Entry<any>[]
Asset?: Asset[]
}
}
interface Entry<T> {
sys: {
id: string
type: 'Entry'
contentType: {
sys: {
id: string
}
}
createdAt: string
updatedAt: string
revision: number
locale: string
}
fields: T
metadata: {
tags: Tag[]
}
}
// Usage example
interface BlogPostFields {
title: string
slug: string
content: Document // Rich text
author: Entry<AuthorFields>
publishDate: string
}
const post = entries.items[0] as Entry<BlogPostFields>
console.log(post.fields.title)
console.log(post.sys.id)GraphQL API
Contentful also offers a GraphQL API for more complex queries.
Endpoint
https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/environments/{ENVIRONMENT}Example queries
# Fetch blog posts
query GetBlogPosts($limit: Int, $skip: Int) {
blogPostCollection(limit: $limit, skip: $skip, order: publishDate_DESC) {
total
items {
sys {
id
}
title
slug
excerpt
publishDate
featuredImage {
url
title
width
height
}
author {
name
avatar {
url
}
}
}
}
}
# Fetch a single post
query GetBlogPost($slug: String!) {
blogPostCollection(where: { slug: $slug }, limit: 1) {
items {
title
slug
content {
json
links {
entries {
inline {
sys {
id
}
... on CodeBlock {
language
code
}
}
block {
sys {
id
}
... on VideoEmbed {
title
embedUrl
}
}
}
assets {
block {
sys {
id
}
url
title
width
height
}
}
}
}
author {
name
bio
}
category {
name
slug
}
tagsCollection {
items {
name
slug
}
}
}
}
}GraphQL client setup
// lib/contentful-graphql.ts
import { GraphQLClient, gql } from 'graphql-request'
const endpoint = `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`
export const graphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
},
})
// For preview
export const previewGraphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.CONTENTFUL_PREVIEW_TOKEN}`,
},
})
// Query helper
export async function fetchGraphQL<T>(
query: string,
variables?: Record<string, any>,
preview = false
): Promise<T> {
const client = preview ? previewGraphQLClient : graphQLClient
return client.request<T>(query, variables)
}Rich Text rendering
Contentful Rich Text is structured JSON, not HTML. It requires a renderer.
Installation
npm install @contentful/rich-text-react-renderer @contentful/rich-text-typesBasic rendering
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import type { Document } from '@contentful/rich-text-types'
interface BlogPostProps {
content: Document
}
function BlogPost({ content }: BlogPostProps) {
return (
<article className="prose prose-lg max-w-none">
{documentToReactComponents(content)}
</article>
)
}Custom renderers
import { documentToReactComponents, Options } from '@contentful/rich-text-react-renderer'
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types'
import Image from 'next/image'
import Link from 'next/link'
const renderOptions: Options = {
renderMark: {
[MARKS.BOLD]: (text) => <strong className="font-bold">{text}</strong>,
[MARKS.ITALIC]: (text) => <em className="italic">{text}</em>,
[MARKS.CODE]: (text) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{text}
</code>
),
},
renderNode: {
// Headings
[BLOCKS.HEADING_1]: (node, children) => (
<h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
),
[BLOCKS.HEADING_2]: (node, children) => (
<h2 className="text-3xl font-bold mt-6 mb-3">{children}</h2>
),
[BLOCKS.HEADING_3]: (node, children) => (
<h3 className="text-2xl font-semibold mt-4 mb-2">{children}</h3>
),
// Paragraphs
[BLOCKS.PARAGRAPH]: (node, children) => (
<p className="mb-4 leading-relaxed">{children}</p>
),
// Lists
[BLOCKS.UL_LIST]: (node, children) => (
<ul className="list-disc list-inside mb-4 space-y-2">{children}</ul>
),
[BLOCKS.OL_LIST]: (node, children) => (
<ol className="list-decimal list-inside mb-4 space-y-2">{children}</ol>
),
// Quotes
[BLOCKS.QUOTE]: (node, children) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-4">
{children}
</blockquote>
),
// Horizontal rule
[BLOCKS.HR]: () => <hr className="my-8 border-gray-200" />,
// Embedded assets (images)
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const { title, description, file } = node.data.target.fields
const { url, details } = file
return (
<figure className="my-8">
<Image
src={`https:${url}`}
alt={description || title}
width={details.image.width}
height={details.image.height}
className="rounded-lg"
/>
{description && (
<figcaption className="text-center text-gray-500 mt-2 text-sm">
{description}
</figcaption>
)}
</figure>
)
},
// Embedded entries (custom components)
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
switch (contentType) {
case 'codeBlock':
return (
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4">
<code className={`language-${entry.fields.language}`}>
{entry.fields.code}
</code>
</pre>
)
case 'videoEmbed':
return (
<div className="aspect-video my-8">
<iframe
src={entry.fields.embedUrl}
title={entry.fields.title}
className="w-full h-full rounded-lg"
allowFullScreen
/>
</div>
)
case 'callout':
return (
<div className={`p-4 rounded-lg my-4 ${
entry.fields.type === 'warning' ? 'bg-yellow-50 border-yellow-200' :
entry.fields.type === 'error' ? 'bg-red-50 border-red-200' :
'bg-blue-50 border-blue-200'
} border`}>
<p className="font-medium">{entry.fields.title}</p>
<p>{entry.fields.message}</p>
</div>
)
default:
return null
}
},
// Inline entries
[INLINES.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
if (contentType === 'inlineCode') {
return (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm">
{entry.fields.code}
</code>
)
}
return null
},
// Hyperlinks
[INLINES.HYPERLINK]: (node, children) => (
<a
href={node.data.uri}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{children}
</a>
),
// Entry hyperlinks (internal links)
[INLINES.ENTRY_HYPERLINK]: (node, children) => {
const entry = node.data.target
const contentType = entry.sys.contentType.sys.id
let href = '/'
if (contentType === 'blogPost') {
href = `/blog/${entry.fields.slug}`
} else if (contentType === 'page') {
href = `/${entry.fields.slug}`
}
return (
<Link href={href} className="text-blue-600 hover:underline">
{children}
</Link>
)
},
// Asset hyperlinks (file downloads)
[INLINES.ASSET_HYPERLINK]: (node, children) => {
const asset = node.data.target
return (
<a
href={`https:${asset.fields.file.url}`}
download
className="text-blue-600 hover:underline"
>
{children} (📥 Download)
</a>
)
},
},
}
// Component usage
function RichTextContent({ content }: { content: Document }) {
return (
<div className="prose prose-lg max-w-none">
{documentToReactComponents(content, renderOptions)}
</div>
)
}TypeScript integration
Generating types
# Installation
npm install contentful-typescript-codegen --save-dev
# Generation (requires Management API token)
npx contentful-typescript-codegen \
--spaceId YOUR_SPACE_ID \
--environment master \
--output src/types/contentful.d.tsConfiguration (alternative with cf-content-types-generator)
npm install cf-content-types-generator --save-dev// package.json
{
"scripts": {
"generate:types": "cf-content-types-generator --spaceId $CONTENTFUL_SPACE_ID --token $CONTENTFUL_MANAGEMENT_TOKEN -o src/types/contentful.ts"
}
}Example of generated types
// src/types/contentful.ts
import type { Entry, Asset } from 'contentful'
export interface IBlogPostFields {
title: string
slug: string
excerpt?: string
content: Document
featuredImage?: Asset
author: Entry<IAuthorFields>
category?: Entry<ICategoryFields>
tags?: Entry<ITagFields>[]
publishDate: string
}
export interface IAuthorFields {
name: string
bio?: string
avatar?: Asset
socialLinks?: ISocialLink[]
}
export interface ICategoryFields {
name: string
slug: string
description?: string
}
export type IBlogPost = Entry<IBlogPostFields>
export type IAuthor = Entry<IAuthorFields>
export type ICategory = Entry<ICategoryFields>Typed client
// lib/contentful.ts
import { createClient, Entry } from 'contentful'
import type { IBlogPost, IBlogPostFields } from '@/types/contentful'
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
})
export async function getBlogPosts(): Promise<IBlogPost[]> {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
order: ['-fields.publishDate'],
})
return entries.items
}
export async function getBlogPostBySlug(slug: string): Promise<IBlogPost | null> {
const entries = await client.getEntries<IBlogPostFields>({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
include: 3,
})
return entries.items[0] || null
}Preview Mode and draft content
Setting up the Preview API
// lib/contentful.ts
import { createClient } from 'contentful'
export function getContentfulClient(preview = false) {
return createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: preview
? process.env.CONTENTFUL_PREVIEW_TOKEN!
: process.env.CONTENTFUL_ACCESS_TOKEN!,
host: preview ? 'preview.contentful.com' : 'cdn.contentful.com',
})
}Next.js Draft Mode
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
// Verify secret
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
// Enable Draft Mode
draftMode().enable()
// Redirect to the path
redirect(`/blog/${slug}`)
}
// app/api/disable-draft/route.ts
export async function GET() {
draftMode().disable()
redirect('/')
}// app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { getContentfulClient } from '@/lib/contentful'
export default async function BlogPost({ params }: { params: { slug: string } }) {
const { isEnabled: preview } = draftMode()
const client = getContentfulClient(preview)
const post = await client.getEntries({
content_type: 'blogPost',
'fields.slug': params.slug,
limit: 1,
})
if (!post.items[0]) {
notFound()
}
return (
<>
{preview && (
<div className="bg-yellow-100 p-4 text-center">
Preview Mode -{' '}
<a href="/api/disable-draft" className="underline">
Exit Preview
</a>
</div>
)}
<article>
<h1>{post.items[0].fields.title}</h1>
{/* ... */}
</article>
</>
)
}Webhooks and revalidation
Configuring webhooks in Contentful
Settings → Webhooks → Add Webhook
URL: https://your-site.com/api/revalidate
Triggers: Entry - Publish, Unpublish, Delete
Headers:
x-contentful-webhook-secret: your-secret-keyNext.js revalidation endpoint
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
interface ContentfulWebhookPayload {
sys: {
id: string
type: string
contentType?: {
sys: {
id: string
}
}
}
fields?: {
slug?: {
'en-US'?: string
}
}
}
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-contentful-webhook-secret')
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 })
}
try {
const payload: ContentfulWebhookPayload = await request.json()
const contentType = payload.sys.contentType?.sys.id
const slug = payload.fields?.slug?.['en-US']
// Revalidate based on content type
switch (contentType) {
case 'blogPost':
revalidateTag('blog-posts')
if (slug) {
revalidatePath(`/blog/${slug}`)
}
revalidatePath('/blog')
break
case 'page':
revalidateTag('pages')
if (slug) {
revalidatePath(`/${slug}`)
}
break
case 'navigation':
revalidateTag('navigation')
revalidatePath('/', 'layout')
break
default:
// Revalidate everything for unknown content types
revalidatePath('/', 'layout')
}
return Response.json({
revalidated: true,
contentType,
slug,
})
} catch (error) {
return Response.json({ message: 'Error revalidating' }, { status: 500 })
}
}Cache tags in Next.js
// lib/contentful.ts
import { unstable_cache } from 'next/cache'
export const getBlogPosts = unstable_cache(
async () => {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
order: ['-fields.publishDate'],
})
return entries.items
},
['blog-posts'],
{
tags: ['blog-posts'],
revalidate: 3600, // Fallback: 1 hour
}
)
export const getBlogPostBySlug = unstable_cache(
async (slug: string) => {
const entries = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
include: 3,
})
return entries.items[0] || null
},
['blog-post'],
{
tags: ['blog-posts'],
revalidate: 3600,
}
)Localization (multi-locale)
Configuring locales
Settings → Locales → Add locale
Default: en-US (English)
Additional: pl (Polish), de (German)Fetching localized content
// Fetch in a specific locale
const polishPosts = await contentfulClient.getEntries({
content_type: 'blogPost',
locale: 'pl',
})
// Fetch all locales at once
const allLocales = await contentfulClient.getEntries({
content_type: 'blogPost',
'fields.slug': 'my-post',
locale: '*', // All locales
})
// Response structure with locale: '*'
{
fields: {
title: {
'en-US': 'My Post',
'pl': 'Mój Post',
'de': 'Mein Beitrag'
}
}
}Fallback chain
// Settings → Locales → Fallback
// Configuration:
// pl → en-US (if no translation exists, use English)
// de → en-US
// In code you will always get a value (from the fallback if no translation exists)Next.js i18n integration
// app/[locale]/blog/[slug]/page.tsx
import { getContentfulClient } from '@/lib/contentful'
interface PageProps {
params: {
locale: string
slug: string
}
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = params
const client = getContentfulClient()
const entries = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
locale: locale,
include: 3,
})
const post = entries.items[0]
// ...
}
// Generating static paths for all locales
export async function generateStaticParams() {
const client = getContentfulClient()
const locales = ['en', 'pl', 'de']
const entries = await client.getEntries({
content_type: 'blogPost',
select: ['fields.slug'],
})
const params = []
for (const entry of entries.items) {
for (const locale of locales) {
params.push({
locale,
slug: entry.fields.slug,
})
}
}
return params
}Content Management API
For programmatic creation and editing of content.
npm install contentful-managementimport { createClient } from 'contentful-management'
const managementClient = createClient({
accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN!,
})
// Get space and environment
const space = await managementClient.getSpace(process.env.CONTENTFUL_SPACE_ID!)
const environment = await space.getEnvironment('master')
// Create a new entry
const entry = await environment.createEntry('blogPost', {
fields: {
title: {
'en-US': 'New Blog Post',
},
slug: {
'en-US': 'new-blog-post',
},
content: {
'en-US': {
nodeType: 'document',
data: {},
content: [
{
nodeType: 'paragraph',
data: {},
content: [
{
nodeType: 'text',
value: 'This is the content.',
marks: [],
data: {},
},
],
},
],
},
},
},
})
// Publish the entry
await entry.publish()
// Update the entry
entry.fields.title['en-US'] = 'Updated Title'
const updatedEntry = await entry.update()
await updatedEntry.publish()
// Upload an asset
const asset = await environment.createAssetFromFiles({
fields: {
title: {
'en-US': 'My Image',
},
file: {
'en-US': {
contentType: 'image/png',
fileName: 'image.png',
file: fs.readFileSync('./image.png'),
},
},
},
})
await asset.processForAllLocales()
await asset.publish()Contentful Apps
Installing Apps
The App Framework allows you to extend Contentful with custom functionality.
Settings → Apps → Marketplace
Popular apps:
- AI Content Generator
- Image focal point
- Compose (page builder)
- Launch (scheduled publishing)
- WorkflowsCustom App development
npx create-contentful-app my-app
cd my-app
npm start// src/locations/Field.tsx
import { FieldExtensionSDK } from '@contentful/app-sdk'
import { useSDK } from '@contentful/react-apps-toolkit'
const Field = () => {
const sdk = useSDK<FieldExtensionSDK>()
const value = sdk.field.getValue()
const handleChange = (newValue: string) => {
sdk.field.setValue(newValue)
}
return (
<div>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
/>
<p>Characters: {(value || '').length}</p>
</div>
)
}
export default FieldPricing
Community (Free)
| Feature | Limit |
|---|---|
| API calls | 25,000/mo |
| Records | 5,000 |
| Users | 5 |
| Locales | 2 |
| Environments | 1 |
| Roles | 2 |
| Content Types | 25 |
Team
| Feature | Limit | Price |
|---|---|---|
| API calls | 500,000/mo | $300/mo |
| Records | 50,000 | |
| Users | 15 | |
| Locales | 5 | |
| Environments | 3 | |
| Roles | Custom |
Enterprise
| Feature | Limit | Price |
|---|---|---|
| API calls | Custom | Custom |
| SSO | ✅ | |
| SLA | 99.99% | |
| Support | Dedicated | |
| Compliance | SOC 2, HIPAA |
FAQ - frequently asked questions
Contentful vs Strapi - which one to choose?
Contentful is a managed SaaS with a global CDN and enterprise features. Strapi is self-hosted open source. Choose Contentful when you need enterprise reliability, scheduled publishing, and workflows. Choose Strapi when you want full control and zero monthly costs.
How does the Contentful CDN work?
Contentful uses a global CDN network (Fastly) with edge locations around the world. Content is cached close to users, ensuring <50ms response times. The cache is automatically invalidated upon publication.
Can I use Contentful with the Next.js App Router?
Yes! Contentful integrates beautifully with Next.js 13+. Use Server Components, Route Handlers for webhooks, and Draft Mode for preview. Remember to set up cache revalidation through webhooks.
How do I migrate data to Contentful?
Use the contentful-migration CLI or the Management API. You can also export/import through contentful-cli. For large migrations, consider the Contentful Migration Tool.
How much does exceeding limits cost?
On paid plans, additional API calls cost ~$10 per 100,000 calls. Additional records cost ~$5 per 1,000. It is better to monitor usage and upgrade your plan when needed.
Does Contentful support versioning?
Yes, every entry has a version history. You can also use Environments for staging/production workflows and snapshots for backups.
Summary
Contentful is an enterprise-grade headless CMS ideal for large teams and organizations that need:
- 99.99% uptime SLA with a global CDN
- Advanced workflows with scheduled publishing
- Enterprise security (SOC 2, HIPAA, GDPR)
- Composable content architecture
- Multi-locale with fallback chains
- Powerful APIs (REST, GraphQL, Management)
If you are building a content-driven application at enterprise scale and need reliability, Contentful is a safe choice used by the largest companies in the world.