We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide39 min read

Contentful

Contentful is an enterprise headless CMS with advanced content modeling, global CDN, webhooks and scheduled publishing capabilities.

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

  1. Content Infrastructure - Nie tylko CMS, ale kompletna platforma content
  2. Globalne CDN - 99.99% uptime, błyskawiczna dostawa treści
  3. Zaawansowany Content Modeling - Elastyczne struktury danych
  4. Composable Content - Reusable components i references
  5. Scheduled Publishing - Planowanie publikacji
  6. Workflows - Procesy zatwierdzania treści
  7. Multi-locale - Natywne wsparcie wielojęzyczności
  8. Enterprise Security - SOC 2, GDPR, SSO, RBAC

Contentful vs Inne CMS

CechaContentfulStrapiSanityPrismic
TypSaaSSelf-hostedSaaSSaaS
Cena startowaFree (25K API calls)FreeFreeFree
Enterprise tier$489+/moCustomCustom$500+/mo
CDNGlobal, built-inTrzeba dodaćBuilt-inBuilt-in
Real-timePreview APIWebhooksNativeWebhooks
Scheduled publishing✅ TakPlugin✅ Tak✅ Tak
Content workflows✅ TakPluginCustom✅ Tak
GraphQL✅ NativePluginGROQ✅ Native
Rich textStructured JSONHTMLPortable TextSlices
TypeScriptcontentful.jsNatywny@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

  1. Zarejestruj się na contentful.com
  2. Utwórz nowy Space (odpowiednik projektu)
  3. 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

Code
Bash
# 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-dev

Podstawowa konfiguracja

TSlib/contentful.ts
TypeScript
// 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
ENV
# .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_token

Content 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

Code
TEXT
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

TypOpisUse Case
Short textTekst do 256 znakówTytuły, slugi, tagi
Long textTekst bez limituOpisy, excerpts
Rich textStrukturalny JSONContent z formatowaniem
IntegerLiczba całkowitaKolejność, ilości
DecimalLiczba zmiennoprzecinkowaCeny, rating
Date & timeData i czasPublish date, events
LocationWspółrzędne GPSMapy, lokalizacje
BooleanTrue/falseFlagi, toggles
JSON objectDowolny JSONCustom data
MediaPliki, obrazyAssets
ReferencePowiązanie z innym entryRelacje

Validation rules

Code
TEXT
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 5MB

Composable Content z komponentami

Contentful pozwala tworzyć reusable komponenty przez embedded entries w Rich Text lub przez References.

Code
TEXT
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

Code
TypeScript
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

Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TEXT
https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/environments/{ENVIRONMENT}

Przykładowe queries

Code
GraphQL
# 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

TSlib/contentful-graphql.ts
TypeScript
// 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

Code
Bash
npm install @contentful/rich-text-react-renderer @contentful/rich-text-types

Podstawowe renderowanie

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
# 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.ts

Konfiguracja (alternatywa z cf-content-types-generator)

Code
Bash
npm install cf-content-types-generator --save-dev
package.json
JSON
// 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

TSsrc/types/contentful.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

TSapp/api/draft/route.ts
TypeScript
// 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('/')
}
TSapp/blog/[slug]/page.tsx
TypeScript
// 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

Code
TEXT
URL: https://your-site.com/api/revalidate
Triggers: Entry - Publish, Unpublish, Delete
Headers:
  x-contentful-webhook-secret: your-secret-key

Next.js Revalidation endpoint

TSapp/api/revalidate/route.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

Code
TEXT
Default: en-US (English)
Additional: pl (Polish), de (German)

Pobieranie zlokalizowanej treści

Code
TypeScript
// 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

Code
TypeScript
// 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

TSapp/[locale]/blog/[slug]/page.tsx
TypeScript
// 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.

Code
Bash
npm install contentful-management
Code
TypeScript
import { 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.

Code
TEXT
Settings → Apps → Marketplace

Popularne apps:
- AI Content Generator
- Image focal point
- Compose (page builder)
- Launch (scheduled publishing)
- Workflows

Custom App development

Code
Bash
npx create-contentful-app my-app
cd my-app
npm start
TSsrc/locations/Field.tsx
TypeScript
// 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 Field

Cennik

Community (Free)

CechaLimit
API calls25,000/mo
Records5,000
Users5
Locales2
Environments1
Roles2
Content Types25

Team

CechaLimitCena
API calls500,000/mo$300/mo
Records50,000
Users15
Locales5
Environments3
RolesCustom

Enterprise

CechaLimitCena
API callsCustomCustom
SSO
SLA99.99%
SupportDedicated
ComplianceSOC 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

  1. Content Infrastructure - Not just a CMS, but a complete content platform
  2. Global CDN - 99.99% uptime, lightning-fast content delivery
  3. Advanced Content Modeling - Flexible data structures
  4. Composable Content - Reusable components and references
  5. Scheduled Publishing - Plan content releases in advance
  6. Workflows - Content approval processes
  7. Multi-locale - Native internationalization support
  8. Enterprise Security - SOC 2, GDPR, SSO, RBAC

Contentful vs other CMS

FeatureContentfulStrapiSanityPrismic
TypeSaaSSelf-hostedSaaSSaaS
Starting priceFree (25K API calls)FreeFreeFree
Enterprise tier$489+/moCustomCustom$500+/mo
CDNGlobal, built-inMust add separatelyBuilt-inBuilt-in
Real-timePreview APIWebhooksNativeWebhooks
Scheduled publishing✅ YesPlugin✅ Yes✅ Yes
Content workflows✅ YesPluginCustom✅ Yes
GraphQL✅ NativePluginGROQ✅ Native
Rich textStructured JSONHTMLPortable TextSlices
TypeScriptcontentful.jsNative@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

  1. Sign up at contentful.com
  2. Create a new Space (equivalent to a project)
  3. 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

Code
Bash
# 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-dev

Basic configuration

TSlib/contentful.ts
TypeScript
// 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
ENV
# .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_token

Content 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

Code
TEXT
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

TypeDescriptionUse case
Short textText up to 256 charactersTitles, slugs, tags
Long textText with no limitDescriptions, excerpts
Rich textStructured JSONFormatted content
IntegerWhole numberOrdering, quantities
DecimalFloating-point numberPrices, ratings
Date & timeDate and timePublish date, events
LocationGPS coordinatesMaps, locations
BooleanTrue/falseFlags, toggles
JSON objectArbitrary JSONCustom data
MediaFiles, imagesAssets
ReferenceLink to another entryRelations

Validation rules

Code
TEXT
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 5MB

Composable Content with components

Contentful allows you to create reusable components through embedded entries in Rich Text or through References.

Code
TEXT
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

Code
TypeScript
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

Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TEXT
https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}/environments/{ENVIRONMENT}

Example queries

Code
GraphQL
# 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

TSlib/contentful-graphql.ts
TypeScript
// 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

Code
Bash
npm install @contentful/rich-text-react-renderer @contentful/rich-text-types

Basic rendering

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
# 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.ts

Configuration (alternative with cf-content-types-generator)

Code
Bash
npm install cf-content-types-generator --save-dev
package.json
JSON
// 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

TSsrc/types/contentful.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

TSapp/api/draft/route.ts
TypeScript
// 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('/')
}
TSapp/blog/[slug]/page.tsx
TypeScript
// 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

Code
TEXT
URL: https://your-site.com/api/revalidate
Triggers: Entry - Publish, Unpublish, Delete
Headers:
  x-contentful-webhook-secret: your-secret-key

Next.js revalidation endpoint

TSapp/api/revalidate/route.ts
TypeScript
// 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

TSlib/contentful.ts
TypeScript
// 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

Code
TEXT
Default: en-US (English)
Additional: pl (Polish), de (German)

Fetching localized content

Code
TypeScript
// 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

Code
TypeScript
// 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

TSapp/[locale]/blog/[slug]/page.tsx
TypeScript
// 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.

Code
Bash
npm install contentful-management
Code
TypeScript
import { 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.

Code
TEXT
Settings → Apps → Marketplace

Popular apps:
- AI Content Generator
- Image focal point
- Compose (page builder)
- Launch (scheduled publishing)
- Workflows

Custom App development

Code
Bash
npx create-contentful-app my-app
cd my-app
npm start
TSsrc/locations/Field.tsx
TypeScript
// 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 Field

Pricing

Community (Free)

FeatureLimit
API calls25,000/mo
Records5,000
Users5
Locales2
Environments1
Roles2
Content Types25

Team

FeatureLimitPrice
API calls500,000/mo$300/mo
Records50,000
Users15
Locales5
Environments3
RolesCustom

Enterprise

FeatureLimitPrice
API callsCustomCustom
SSO
SLA99.99%
SupportDedicated
ComplianceSOC 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.