Skip to main content
Back to Blog
Tutorials
3 min read
December 15, 2024

How to Implement Headless CMS Integration in Next.js

Integrate a headless CMS with Next.js using Sanity and Contentful as examples, with ISR, preview mode, and type-safe content.

Ryel Banfield

Founder & Lead Developer

A headless CMS lets content teams manage content independently while developers build fast, modern frontends.

Option 1: Sanity

Install Sanity

pnpm add next-sanity @sanity/image-url

Configure the Client

// lib/sanity/client.ts
import { createClient } from "next-sanity";

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

Define GROQ Queries

// lib/sanity/queries.ts
import { groq } from "next-sanity";

export const allPostsQuery = groq`
  *[_type == "post" && defined(slug)] | order(publishedAt desc) {
    _id,
    title,
    slug,
    publishedAt,
    excerpt,
    "author": author->{name, image},
    mainImage {
      asset->{url, metadata {dimensions}},
      alt
    },
    "categories": categories[]->title
  }
`;

export const postBySlugQuery = groq`
  *[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    slug,
    publishedAt,
    body,
    excerpt,
    "author": author->{name, image, bio},
    mainImage {
      asset->{url, metadata {dimensions}},
      alt
    },
    "categories": categories[]->title,
    "relatedPosts": *[_type == "post" && slug.current != $slug && count(categories[@._ref in ^.^.categories[]._ref]) > 0] | order(publishedAt desc) [0...3] {
      title,
      slug,
      publishedAt,
      excerpt
    }
  }
`;

Fetch in a Server Component

// app/(site)/blog/page.tsx
import { client } from "@/lib/sanity/client";
import { allPostsQuery } from "@/lib/sanity/queries";
import Link from "next/link";

export const revalidate = 60; // ISR: revalidate every 60 seconds

interface Post {
  _id: string;
  title: string;
  slug: { current: string };
  publishedAt: string;
  excerpt: string;
  author: { name: string };
}

export default async function BlogPage() {
  const posts = await client.fetch<Post[]>(allPostsQuery);

  return (
    <div className="max-w-3xl mx-auto py-16 px-4">
      <h1 className="text-3xl font-bold mb-8">Blog</h1>
      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post._id}>
            <Link href={`/blog/${post.slug.current}`}>
              <h2 className="text-xl font-semibold hover:underline">
                {post.title}
              </h2>
            </Link>
            <p className="text-sm text-muted-foreground mt-1">
              {new Date(post.publishedAt).toLocaleDateString()} by {post.author.name}
            </p>
            <p className="text-muted-foreground mt-2">{post.excerpt}</p>
          </article>
        ))}
      </div>
    </div>
  );
}

On-Demand Revalidation with Webhooks

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

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get("secret");

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
  }

  const body = await request.json();

  // Revalidate based on the document type
  if (body._type === "post") {
    revalidatePath("/blog");
    if (body.slug?.current) {
      revalidatePath(`/blog/${body.slug.current}`);
    }
  }

  revalidateTag("cms-content");

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Option 2: Contentful

Install Contentful SDK

pnpm add contentful

Configure the Client

// lib/contentful/client.ts
import { createClient } from "contentful";

export const contentful = 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_TOKEN!,
  host: "preview.contentful.com",
});

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

Type-Safe Content

// lib/contentful/types.ts
import type { Entry, Asset } from "contentful";

export interface BlogPostFields {
  title: string;
  slug: string;
  body: any; // Rich text document
  excerpt: string;
  featuredImage: Asset;
  author: Entry<AuthorFields>;
  publishedDate: string;
  tags: string[];
}

export interface AuthorFields {
  name: string;
  bio: string;
  avatar: Asset;
}

export type BlogPost = Entry<BlogPostFields>;

Fetch Content

// lib/contentful/queries.ts
import { getClient } from "./client";
import type { BlogPost } from "./types";

export async function getAllPosts(preview = false): Promise<BlogPost[]> {
  const client = getClient(preview);
  const entries = await client.getEntries<BlogPost["fields"]>({
    content_type: "blogPost",
    order: ["-fields.publishedDate"],
    include: 2,
  });

  return entries.items as BlogPost[];
}

export async function getPostBySlug(
  slug: string,
  preview = false
): Promise<BlogPost | null> {
  const client = getClient(preview);
  const entries = await client.getEntries<BlogPost["fields"]>({
    content_type: "blogPost",
    "fields.slug": slug,
    include: 2,
    limit: 1,
  });

  return (entries.items[0] as BlogPost) ?? null;
}

Draft Mode (Preview)

// 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");

  if (secret !== process.env.PREVIEW_SECRET) {
    return new Response("Invalid token", { status: 401 });
  }

  const draft = await draftMode();
  draft.enable();
  redirect(slug ?? "/");
}
// app/(site)/blog/[slug]/page.tsx
import { draftMode } from "next/headers";
import { getPostBySlug } from "@/lib/contentful/queries";
import { notFound } from "next/navigation";

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { isEnabled: preview } = await draftMode();
  const post = await getPostBySlug(slug, preview);

  if (!post) notFound();

  return (
    <article className="max-w-3xl mx-auto py-16 px-4">
      {preview && (
        <div className="bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm px-4 py-2 rounded mb-6">
          Preview mode — <a href="/api/draft/disable" className="underline">Exit</a>
        </div>
      )}
      <h1 className="text-3xl font-bold">{post.fields.title}</h1>
      {/* Render rich text */}
    </article>
  );
}

Which CMS to Choose?

FeatureSanityContentful
Query languageGROQ (flexible)REST API
Real-time previewBuilt-inVia preview API
Free tierGenerousLimited
Rich textPortable TextStructured
Image optimizationBuilt-in CDNBuilt-in CDN
CustomizationHighly customizable studioLimited

Need a CMS-Powered Website?

We build headless CMS integrations that empower your content team. Contact us to get started.

CMSheadlessSanityContentfulNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles