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?
| Feature | Sanity | Contentful |
|---|---|---|
| Query language | GROQ (flexible) | REST API |
| Real-time preview | Built-in | Via preview API |
| Free tier | Generous | Limited |
| Rich text | Portable Text | Structured |
| Image optimization | Built-in CDN | Built-in CDN |
| Customization | Highly customizable studio | Limited |
Need a CMS-Powered Website?
We build headless CMS integrations that empower your content team. Contact us to get started.