Next.js
CMS Integration with Next.js
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:
- Set up a free Contentful or Sanity account
- Create a content model for a blog with posts, authors, and categories
- Integrate the CMS with a Next.js application
- Implement content preview mode
- Set up webhooks for automatic revalidation
- 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