Skip to main content
Technology & EngineeringCms Services360 lines

Contentful

Build with Contentful for headless content management. Use this skill when the

Quick Summary31 lines
You are a content platform specialist who integrates Contentful into projects.
Contentful is a composable content platform that provides structured content
via REST and GraphQL APIs. Content is organized into spaces, environments,
and content types with strong typing and localization built in.

## Key Points

- Define TypeScript skeleton types for every content type — catch errors at build time
- Use environments for schema changes — never edit master content model directly
- Set `include` depth carefully — deep resolution increases response size and latency
- Use GraphQL for complex nested queries, REST for simpler list/detail patterns
- Cache delivery API responses with `next: { tags: [...] }` and revalidate via webhooks
- Use the Images API query parameters for responsive images (`?w=400&fm=webp&q=80`)
- Plan localization at content model design time, not as an afterthought
- Run migrations in CI/CD with `contentful-migration` for reproducible schema changes
- Fetching all entries without `limit` — Contentful caps at 1000 and large payloads are slow
- Using `include: 10` everywhere — deep resolution bloats responses; fetch only what you need
- Storing the management token in client-side code — it grants full write access to your space
- Hardcoding locale strings — use a locale map or i18n config for maintainability

## Quick Example

```bash
npm install contentful contentful-rich-text-react-renderer @contentful/rich-text-types
# For content management (writes)
npm install contentful-management
# For migrations
npm install contentful-migration
```
skilldb get cms-services-skills/ContentfulFull skill: 360 lines
Paste into your CLAUDE.md or agent config

Contentful Integration

You are a content platform specialist who integrates Contentful into projects. Contentful is a composable content platform that provides structured content via REST and GraphQL APIs. Content is organized into spaces, environments, and content types with strong typing and localization built in.

Core Philosophy

Content models are your schema

Content types in Contentful define the shape of your content. Treat them like database schemas — plan them carefully, keep them normalized, and version them through environments. A well-designed content model makes every downstream integration simpler.

Environments for safe iteration

Contentful environments work like Git branches for your content model. Create an environment to test schema changes, run migrations, and merge back. Never modify the master environment directly in production workflows.

Locale-first content

Contentful supports per-field localization. When designing content types, decide which fields need localization upfront. Retrofitting localization onto existing fields is significantly more work.

Setup

Install

npm install contentful contentful-rich-text-react-renderer @contentful/rich-text-types
# For content management (writes)
npm install contentful-management
# For migrations
npm install contentful-migration

Environment variables

CONTENTFUL_SPACE_ID=your-space-id
CONTENTFUL_ACCESS_TOKEN=your-delivery-token
CONTENTFUL_PREVIEW_TOKEN=your-preview-token
CONTENTFUL_MANAGEMENT_TOKEN=your-management-token
CONTENTFUL_ENVIRONMENT=master
CONTENTFUL_WEBHOOK_SECRET=your-webhook-secret

Client configuration

// lib/contentful.ts
import { createClient, type EntrySkeletonType } from 'contentful';

export const contentfulClient = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
  environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'master',
});

export const previewClient = createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
  environment: process.env.CONTENTFUL_ENVIRONMENT ?? 'master',
  host: 'preview.contentful.com',
});

export function getClient(preview = false) {
  return preview ? previewClient : contentfulClient;
}

Key Techniques

Type-safe content types

// types/contentful.ts
import type { EntryFieldTypes, EntrySkeletonType } from 'contentful';

export interface BlogPostSkeleton extends EntrySkeletonType {
  contentTypeId: 'blogPost';
  fields: {
    title: EntryFieldTypes.Text;
    slug: EntryFieldTypes.Text;
    body: EntryFieldTypes.RichText;
    featuredImage: EntryFieldTypes.AssetLink;
    author: EntryFieldTypes.EntryLink<AuthorSkeleton>;
    publishDate: EntryFieldTypes.Date;
    tags: EntryFieldTypes.Array<EntryFieldTypes.Symbol>;
    locale: EntryFieldTypes.Text;
  };
}

export interface AuthorSkeleton extends EntrySkeletonType {
  contentTypeId: 'author';
  fields: {
    name: EntryFieldTypes.Text;
    bio: EntryFieldTypes.RichText;
    avatar: EntryFieldTypes.AssetLink;
  };
}

Fetching entries with type safety

// lib/api.ts
import { contentfulClient, getClient } from './contentful';
import type { BlogPostSkeleton, AuthorSkeleton } from '@/types/contentful';

export async function getPosts(preview = false) {
  const client = getClient(preview);
  const entries = await client.getEntries<BlogPostSkeleton>({
    content_type: 'blogPost',
    order: ['-fields.publishDate'],
    include: 2, // resolve 2 levels of linked entries
    limit: 20,
  });

  return entries.items.map((item) => ({
    id: item.sys.id,
    title: item.fields.title,
    slug: item.fields.slug,
    body: item.fields.body,
    publishDate: item.fields.publishDate,
    author: item.fields.author
      ? {
          name: (item.fields.author as any).fields.name,
          avatar: (item.fields.author as any).fields.avatar?.fields.file.url,
        }
      : null,
    featuredImage: item.fields.featuredImage
      ? `https:${(item.fields.featuredImage as any).fields.file.url}`
      : null,
  }));
}

export async function getPostBySlug(slug: string, preview = false) {
  const client = getClient(preview);
  const entries = await client.getEntries<BlogPostSkeleton>({
    content_type: 'blogPost',
    'fields.slug': slug,
    include: 2,
    limit: 1,
  });

  return entries.items[0] ?? null;
}

Rich text rendering

// components/RichText.tsx
import {
  documentToReactComponents,
  type Options,
} from '@contentful/rich-text-react-renderer';
import { BLOCKS, INLINES, type Document } from '@contentful/rich-text-types';
import Image from 'next/image';

const options: Options = {
  renderNode: {
    [BLOCKS.EMBEDDED_ASSET]: (node) => {
      const { url, fileName } = node.data.target.fields.file;
      const { width, height } = node.data.target.fields.file.details.image;
      return (
        <Image
          src={`https:${url}`}
          alt={node.data.target.fields.description || fileName}
          width={width}
          height={height}
        />
      );
    },
    [BLOCKS.EMBEDDED_ENTRY]: (node) => {
      const contentType = node.data.target.sys.contentType.sys.id;
      if (contentType === 'codeBlock') {
        return (
          <pre data-language={node.data.target.fields.language}>
            <code>{node.data.target.fields.code}</code>
          </pre>
        );
      }
      return null;
    },
    [INLINES.HYPERLINK]: (node, children) => (
      <a href={node.data.uri} target="_blank" rel="noopener noreferrer">
        {children}
      </a>
    ),
  },
};

export function RichText({ document }: { document: Document }) {
  return <>{documentToReactComponents(document, options)}</>;
}

GraphQL API

// lib/contentful-graphql.ts
const CONTENTFUL_GRAPHQL_URL = `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/${process.env.CONTENTFUL_ENVIRONMENT}`;

export async function fetchGraphQL<T>(
  query: string,
  variables: Record<string, any> = {},
  preview = false
): Promise<T> {
  const response = await fetch(CONTENTFUL_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${
        preview
          ? process.env.CONTENTFUL_PREVIEW_TOKEN
          : process.env.CONTENTFUL_ACCESS_TOKEN
      }`,
    },
    body: JSON.stringify({ query, variables }),
    next: { tags: ['contentful'] },
  });

  const json = await response.json();
  if (json.errors) {
    throw new Error(json.errors.map((e: any) => e.message).join('\n'));
  }
  return json.data;
}

// Usage
const GET_POSTS = `
  query GetPosts($limit: Int!) {
    blogPostCollection(limit: $limit, order: publishDate_DESC) {
      items {
        sys { id }
        title
        slug
        publishDate
        author { name avatar { url } }
        featuredImage { url width height }
      }
    }
  }
`;

const data = await fetchGraphQL<{ blogPostCollection: { items: any[] } }>(
  GET_POSTS,
  { limit: 10 }
);

Localization

// Fetch localized content
const entries = await contentfulClient.getEntries<BlogPostSkeleton>({
  content_type: 'blogPost',
  locale: 'de-DE', // German locale
});

// Fetch all locales at once
const entriesAllLocales = await contentfulClient.getEntries<BlogPostSkeleton>({
  content_type: 'blogPost',
  locale: '*', // returns all locales
});

// Access specific locale
const title = entriesAllLocales.items[0].fields.title; // { 'en-US': '...', 'de-DE': '...' }

Content migrations

// migrations/001-add-category.ts
import type { MigrationFunction } from 'contentful-migration';

const migration: MigrationFunction = (migration) => {
  const category = migration.createContentType('category')
    .name('Category')
    .displayField('name');

  category.createField('name')
    .type('Symbol')
    .name('Name')
    .required(true);

  category.createField('slug')
    .type('Symbol')
    .name('Slug')
    .required(true)
    .validations([{ unique: true }]);

  // Add category reference to existing blogPost type
  const blogPost = migration.editContentType('blogPost');
  blogPost.createField('category')
    .type('Link')
    .linkType('Entry')
    .name('Category')
    .validations([{ linkContentType: ['category'] }]);
};

export default migration;

Webhook revalidation

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import crypto from 'crypto';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('x-contentful-signature');
  const secret = process.env.CONTENTFUL_WEBHOOK_SECRET!;

  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  if (signature !== expectedSig) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(body);
  const contentType = payload.sys?.contentType?.sys?.id;

  if (contentType === 'blogPost') {
    revalidateTag('contentful');
  }

  return Response.json({ revalidated: true });
}

Best Practices

  • Define TypeScript skeleton types for every content type — catch errors at build time
  • Use environments for schema changes — never edit master content model directly
  • Set include depth carefully — deep resolution increases response size and latency
  • Use GraphQL for complex nested queries, REST for simpler list/detail patterns
  • Cache delivery API responses with next: { tags: [...] } and revalidate via webhooks
  • Use the Images API query parameters for responsive images (?w=400&fm=webp&q=80)
  • Plan localization at content model design time, not as an afterthought
  • Run migrations in CI/CD with contentful-migration for reproducible schema changes

Anti-Patterns

  • Fetching all entries without limit — Contentful caps at 1000 and large payloads are slow
  • Using include: 10 everywhere — deep resolution bloats responses; fetch only what you need
  • Storing the management token in client-side code — it grants full write access to your space
  • Hardcoding locale strings — use a locale map or i18n config for maintainability
  • Modifying the master environment schema without a migration — breaks reproducibility
  • Not handling the case where linked entries are unpublished — they return as unresolved links
  • Ignoring rate limits — the delivery API is rate-limited; batch requests and cache responses
  • Building URL paths from entry IDs instead of slugs — IDs are opaque and not human-readable

Install this skill directly: skilldb add cms-services-skills

Get CLI access →