Next.js

CMS Integration with Next.js

45 min Lesson 37 of 40

CMS Integration with Next.js

Headless CMS platforms provide a powerful way to manage content separately from your Next.js application. This lesson covers integrating popular CMS solutions, content modeling, and building dynamic content-driven applications.

What is a Headless CMS?

A headless CMS is a content management system that provides content via an API, without a built-in frontend. This architecture offers several advantages:

  • Separation of concerns: Content management is decoupled from presentation
  • Flexibility: Use the same content across multiple platforms (web, mobile, IoT)
  • Developer experience: Build with modern frameworks like Next.js
  • Content editor experience: Non-technical users can manage content easily
  • Scalability: API-first architecture scales well

Popular Headless CMS Options

Top CMS Platforms for Next.js:
  • Contentful: Enterprise-grade, excellent API, rich ecosystem
  • Sanity: Real-time collaboration, customizable studio, GROQ queries
  • Strapi: Open-source, self-hosted option, full control
  • Prismic: Slice Machine for component-based content
  • DatoCMS: Great for multi-language sites, GraphQL API
  • Hygraph (GraphCMS): GraphQL-native, federation support

Contentful Integration

Let's start with Contentful, one of the most popular headless CMS platforms:

Install Contentful SDK:
npm install contentful
npm install -D @types/contentful
.env.local:
CONTENTFUL_SPACE_ID=your_space_id
CONTENTFUL_ACCESS_TOKEN=your_access_token
CONTENTFUL_PREVIEW_ACCESS_TOKEN=your_preview_token
lib/contentful.ts:
import { createClient } from 'contentful';

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

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

export function getClient(preview: boolean = false) {
  return preview ? previewClient : contentfulClient;
}
Content Model Example - Blog Post:
// Define TypeScript types for your content model
export interface BlogPostFields {
  title: string;
  slug: string;
  excerpt: string;
  content: Document; // Rich text
  featuredImage: Asset;
  author: Entry<AuthorFields>;
  publishDate: string;
  tags: string[];
  seoTitle?: string;
  seoDescription?: string;
}

export type BlogPost = Entry<BlogPostFields>;
lib/contentful/blog.ts:
import { getClient } from './contentful';
import type { BlogPost } from './types';

export async function getAllBlogPosts(
  limit: number = 100,
  preview: boolean = false
): Promise<BlogPost[]> {
  const client = getClient(preview);

  const response = await client.getEntries<BlogPostFields>({
    content_type: 'blogPost',
    limit,
    order: '-fields.publishDate',
  });

  return response.items;
}

export async function getBlogPost(
  slug: string,
  preview: boolean = false
): Promise<BlogPost | null> {
  const client = getClient(preview);

  const response = await client.getEntries<BlogPostFields>({
    content_type: 'blogPost',
    'fields.slug': slug,
    limit: 1,
  });

  return response.items[0] || null;
}

export async function getBlogPostsByTag(
  tag: string,
  limit: number = 10
): Promise<BlogPost[]> {
  const client = getClient();

  const response = await client.getEntries<BlogPostFields>({
    content_type: 'blogPost',
    'fields.tags[in]': tag,
    limit,
    order: '-fields.publishDate',
  });

  return response.items;
}
app/blog/page.tsx:
import { getAllBlogPosts } from '@/lib/contentful/blog';
import BlogCard from '@/components/BlogCard';

export const revalidate = 3600; // Revalidate every hour

export default async function BlogPage() {
  const posts = await getAllBlogPosts();

  return (
    <div className="blog-page">
      <h1>Blog</h1>
      <div className="blog-grid">
        {posts.map((post) => (
          <BlogCard key={post.sys.id} post={post} />
        ))}
      </div>
    </div>
  );
}
app/blog/[slug]/page.tsx:
import { notFound } from 'next/navigation';
import { getAllBlogPosts, getBlogPost } from '@/lib/contentful/blog';
import RichText from '@/components/RichText';

export async function generateStaticParams() {
  const posts = await getAllBlogPosts();

  return posts.map((post) => ({
    slug: post.fields.slug,
  }));
}

export async function generateMetadata({ params }: Props) {
  const post = await getBlogPost(params.slug);

  if (!post) return {};

  return {
    title: post.fields.seoTitle || post.fields.title,
    description: post.fields.seoDescription || post.fields.excerpt,
    openGraph: {
      images: [post.fields.featuredImage.fields.file.url],
    },
  };
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="blog-post">
      <header>
        <h1>{post.fields.title}</h1>
        <p className="excerpt">{post.fields.excerpt}</p>
        <div className="meta">
          <time>{new Date(post.fields.publishDate).toLocaleDateString()}</time>
          <span>By {post.fields.author.fields.name}</span>
        </div>
      </header>

      <img
        src={post.fields.featuredImage.fields.file.url}
        alt={post.fields.featuredImage.fields.title}
        width={1200}
        height={630}
      />

      <div className="content">
        <RichText content={post.fields.content} />
      </div>

      <footer>
        <div className="tags">
          {post.fields.tags.map((tag) => (
            <span key={tag} className="tag">
              {tag}
            </span>
          ))}
        </div>
      </footer>
    </article>
  );
}

Sanity CMS Integration

Sanity offers a highly customizable content studio and powerful query language (GROQ):

Install Sanity:
npm install next-sanity @sanity/image-url
npm install -D @sanity/vision
sanity.config.ts:
import { defineConfig } from 'sanity';
import { deskTool } from 'sanity/desk';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemas';

export default defineConfig({
  name: 'default',
  title: 'My Next.js Site',

  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,

  plugins: [deskTool(), visionTool()],

  schema: {
    types: schemaTypes,
  },
});
schemas/blogPost.ts:
import { defineType, defineField } from 'sanity';

export default defineType({
  name: 'blogPost',
  title: 'Blog Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: {
        source: 'title',
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'excerpt',
      title: 'Excerpt',
      type: 'text',
      rows: 4,
    }),
    defineField({
      name: 'content',
      title: 'Content',
      type: 'array',
      of: [
        { type: 'block' },
        {
          type: 'image',
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
            },
          ],
        },
        {
          type: 'code',
          options: {
            language: 'javascript',
          },
        },
      ],
    }),
    defineField({
      name: 'featuredImage',
      title: 'Featured Image',
      type: 'image',
      options: {
        hotspot: true,
      },
    }),
    defineField({
      name: 'author',
      title: 'Author',
      type: 'reference',
      to: [{ type: 'author' }],
    }),
    defineField({
      name: 'publishedAt',
      title: 'Published at',
      type: 'datetime',
    }),
    defineField({
      name: 'categories',
      title: 'Categories',
      type: 'array',
      of: [{ type: 'reference', to: { type: 'category' } }],
    }),
  ],
  preview: {
    select: {
      title: 'title',
      author: 'author.name',
      media: 'featuredImage',
    },
    prepare(selection) {
      const { author } = selection;
      return {
        ...selection,
        subtitle: author && \`by ${author}\`,
      };
    },
  },
});
lib/sanity.ts:
import { createClient } from 'next-sanity';
import imageUrlBuilder from '@sanity/image-url';

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn: process.env.NODE_ENV === 'production',
});

const builder = imageUrlBuilder(client);

export function urlFor(source: any) {
  return builder.image(source);
}
GROQ queries (lib/sanity/queries.ts):
// Get all published blog posts
export const allPostsQuery = \`
  *[_type == "blogPost" && publishedAt < now()] | order(publishedAt desc) {
    _id,
    title,
    slug,
    excerpt,
    featuredImage,
    publishedAt,
    author->{
      name,
      image
    },
    categories[]->{
      title,
      slug
    }
  }
\`;

// Get single post by slug
export const postBySlugQuery = \`
  *[_type == "blogPost" && slug.current == $slug][0] {
    _id,
    title,
    slug,
    excerpt,
    content,
    featuredImage,
    publishedAt,
    author->{
      name,
      image,
      bio
    },
    categories[]->{
      title,
      slug
    }
  }
\`;

// Get posts by category
export const postsByCategoryQuery = \`
  *[_type == "blogPost" && $categorySlug in categories[]->slug.current] | order(publishedAt desc) {
    _id,
    title,
    slug,
    excerpt,
    featuredImage,
    publishedAt
  }
\`;
Fetching data from Sanity:
import { client } from '@/lib/sanity';
import { allPostsQuery, postBySlugQuery } from '@/lib/sanity/queries';

export async function getAllPosts() {
  return await client.fetch(allPostsQuery);
}

export async function getPostBySlug(slug: string) {
  return await client.fetch(postBySlugQuery, { slug });
}

Strapi Integration

Strapi is an open-source, self-hosted headless CMS:

.env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token
lib/strapi.ts:
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL;
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;

async function fetchAPI(
  path: string,
  options: RequestInit = {}
): Promise<any> {
  const defaultOptions: RequestInit = {
    headers: {
      'Content-Type': 'application/json',
      Authorization: \`Bearer ${STRAPI_TOKEN}\`,
    },
  };

  const mergedOptions = {
    ...defaultOptions,
    ...options,
    headers: {
      ...defaultOptions.headers,
      ...options.headers,
    },
  };

  const response = await fetch(\`${STRAPI_URL}/api${path}\`, mergedOptions);

  if (!response.ok) {
    throw new Error(\`Strapi API error: ${response.statusText}\`);
  }

  return await response.json();
}

export async function getArticles() {
  const data = await fetchAPI('/articles?populate=*');
  return data.data;
}

export async function getArticle(slug: string) {
  const data = await fetchAPI(\`/articles?filters[slug][$eq]=${slug}&populate=*\`);
  return data.data[0];
}

export async function getCategories() {
  const data = await fetchAPI('/categories?populate=*');
  return data.data;
}

Content Preview Mode

Implement draft preview functionality:

app/api/preview/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 });
  }

  // Enable draft mode
  draftMode().enable();

  // Redirect to the path from the fetched post
  redirect(slug || '/');
}
app/api/preview/disable/route.ts:
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
  draftMode().disable();
  redirect('/');
}
Using draft mode in pages:
import { draftMode } from 'next/headers';
import { getBlogPost } from '@/lib/contentful/blog';

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const { isEnabled } = draftMode();
  const post = await getBlogPost(params.slug, isEnabled);

  return (
    <>
      {isEnabled && (
        <div className="preview-banner">
          Preview Mode
          <a href="/api/preview/disable">Exit Preview</a>
        </div>
      )}

      <article>
        {/* render post */}
      </article>
    </>
  );
}

Webhook Integration for Revalidation

Set up webhooks to automatically revalidate when content changes:

app/api/revalidate/route.ts:
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidate-secret');

  // Verify secret
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json(
      { message: 'Invalid secret' },
      { status: 401 }
    );
  }

  const body = await request.json();

  try {
    // Revalidate specific paths
    if (body.path) {
      await revalidatePath(body.path);
    }

    // Or revalidate by tag
    if (body.tag) {
      revalidateTag(body.tag);
    }

    return NextResponse.json({ revalidated: true, now: Date.now() });
  } catch (error) {
    return NextResponse.json(
      { message: 'Error revalidating', error },
      { status: 500 }
    );
  }
}
Exercise:
  1. Set up a free Contentful or Sanity account
  2. Create a content model for a blog with posts, authors, and categories
  3. Integrate the CMS with a Next.js application
  4. Implement content preview mode
  5. Set up webhooks for automatic revalidation
  6. Add image optimization with the CMS image API
Best Practices:
  • Use TypeScript types for your content models
  • Implement proper error handling for API calls
  • Cache CMS responses appropriately
  • Use preview mode for content editors
  • Set up webhooks for automatic revalidation
  • Optimize images using CMS image APIs
  • Consider using GraphQL for complex queries
  • Implement proper security with API tokens