Content Management Systems
Sanity, Payload CMS, and Contentful — headless CMS setup, content modeling, and integration with Next.js App Router for AI-assisted development.
Content Management Systems
Sanity, Payload CMS, and Contentful — headless CMS setup, content modeling, and integration with Next.js App Router for AI-assisted development.
When to Use What
| Feature | Sanity | Payload CMS | Contentful |
|---|---|---|---|
| Pricing (free tier) | Free (3 users, 500k API CDN requests/mo) | Free & open-source (self-hosted) | Free (1 space, 5 users, 25k records) |
| Open-source | Studio is open-source, backend is hosted | Fully open-source (MIT) | No |
| Self-hostable | No (hosted backend + self-hosted Studio) | Yes (your server, your database) | No |
| Query language | GROQ (custom) + GraphQL | Local DB queries (Drizzle/Mongoose) | GraphQL + REST |
| Real-time preview | Excellent (native live preview via next-sanity) | Good (built-in draft preview) | Good (Preview API + webhooks) |
| TypeScript native | Strong (schema codegen, typed GROQ) | Excellent (built in TypeScript from ground up) | Moderate (codegen available, SDK typed) |
| Localization | Built-in field-level + document-level | Plugin-based or field-level | Enterprise-grade built-in |
| Media management | Sanity CDN with on-the-fly transforms | Local/S3 uploads with image resizing | Built-in DAM with CDN |
| Best for | Most projects, blogs, marketing sites, apps | Full control, self-hosted, complex access control | Enterprise teams, multi-market, heavy localization |
Opinionated recommendation: Start with Sanity for most projects. The developer experience is best-in-class, GROQ is powerful and intuitive, and the real-time collaboration features are unmatched. Switch to Payload CMS when you need full ownership of your data, complex role-based access control, or cannot use a hosted backend for compliance reasons. Choose Contentful for enterprise teams with existing contracts, heavy multi-locale requirements, or when non-technical editors need a polished out-of-the-box UI.
Principles
1. When to Use a CMS (vs a Database)
A CMS is the right choice when non-developers need to create, edit, and publish content. If every piece of content is created and consumed by code, use a database directly.
Use a CMS when:
- Marketing teams need to update landing pages, blog posts, or product descriptions without developer involvement
- Content has a lifecycle: drafts, review, scheduled publishing, versioning
- You need a visual editing experience for rich text, images, and structured content
- Multiple people collaborate on content simultaneously
- Content is consumed across multiple channels (web, mobile, email)
Use a database when:
- All data is generated by application logic (user profiles, transactions, analytics)
- Content changes require code changes anyway (feature flags, configs)
- You need complex relational queries that CMS query languages handle poorly
The hybrid approach is common. Use a CMS for editorial content (blog posts, landing pages, FAQs) and a database for application data (users, orders, settings). Connect them via shared identifiers when needed:
// Fetch product content from CMS, inventory from database
const [content, inventory] = await Promise.all([
sanityClient.fetch(`*[_type == "product" && slug.current == $slug][0]`, { slug }),
db.inventory.findUnique({ where: { productSlug: slug } }),
]);2. Content Modeling Best Practices
Good content modeling is the difference between a CMS that editors love and one they fight against. Model content around meaning, not layout.
Separate content from presentation. A "Hero Section" document type is a layout concept. A "Campaign" or "Promotion" with a title, description, image, and CTA is a content concept. The component that renders it decides whether it is a hero, a card, or a banner.
Use references over duplication. If an author appears on multiple blog posts, create an "Author" document and reference it. Do not embed author fields on every post.
Keep documents granular. A "Page" document with 30 fields for every section is a nightmare to edit. Break it into composable blocks: a page references an array of block types (hero, feature grid, testimonial carousel) that editors can reorder.
Name fields for editors, not developers. The field title in the CMS should say "Call to Action Text" not ctaText. Use the title and description properties to guide editors.
// Good: Meaningful content types with references
// Author (standalone document)
// BlogPost → references Author, Category
// Category (standalone document)
// Bad: Everything embedded
// BlogPost with author name, author bio, author image, category name...3. Preview and Draft Mode in Next.js
All three CMS tools support a draft/preview mode where editors can see unpublished changes before they go live. The pattern in Next.js App Router is the same regardless of CMS:
- The CMS provides a preview URL that includes a secret token
- A Next.js API route validates the token and enables Draft Mode via
draftMode().enable() - Data-fetching functions check
draftMode().isEnabledand fetch draft content when true - A "disable preview" button calls
draftMode().disable()to return to published content
// app/api/draft/route.ts — generic draft mode handler
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");
// Validate the secret — never skip this
if (secret !== process.env.CMS_PREVIEW_SECRET) {
return new Response("Invalid token", { status: 401 });
}
(await draftMode()).enable();
redirect(slug ?? "/");
}// app/api/draft/disable/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET() {
(await draftMode()).disable();
redirect("/");
}4. Webhook-Triggered Rebuilds and On-Demand Revalidation
Static generation with on-demand revalidation is the ideal deployment model for CMS-driven sites. Content is pre-rendered at build time for speed, then selectively revalidated when editors publish changes.
The flow:
- Editor publishes content in the CMS
- CMS fires a webhook to your Next.js revalidation endpoint
- The endpoint validates the webhook signature and calls
revalidateTag()orrevalidatePath() - Next.js regenerates only the affected pages on the next request
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate webhook secret (implementation varies by CMS)
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
// Revalidate by content type tag
const { _type, slug } = body;
if (_type === "post") {
revalidateTag("posts");
if (slug) revalidateTag(`post-${slug}`);
}
if (_type === "page") {
revalidateTag("pages");
if (slug) revalidateTag(`page-${slug}`);
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}Always use tag-based revalidation over path-based. Tags are more precise and avoid revalidating unrelated pages. Tag every fetch call with the content type it belongs to.
5. Image and Asset Handling
Every CMS provides image hosting and CDN delivery, but the implementation details differ significantly. The goal is always the same: serve responsive, optimized images with minimal configuration.
General rules:
- Always use the CMS image CDN — do not download images and serve them from your own server
- Set
images.remotePatternsinnext.config.tsto allow the CMS CDN domain - Use Next.js
<Image>component for automatic optimization, lazy loading, and responsive sizing - Request only the image dimensions you need — do not fetch a 4000px image for a 400px thumbnail
// next.config.ts — allow CMS image domains
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.sanity.io" },
{ protocol: "https", hostname: "images.ctfassets.net" },
// Payload: your own domain if self-hosted
],
},
};
export default nextConfig;6. Localization Considerations
If your project serves multiple languages or regions, CMS localization strategy matters from day one. Retrofitting localization is painful.
Field-level localization stores translations alongside the original content in the same document. Best for small sites with 2-3 languages. Each field has a value per locale. Sanity and Payload support this natively.
Document-level localization creates a separate document per locale. Better for large sites where content varies significantly between markets. Contentful uses this model by default.
Implementation pattern in Next.js:
// app/[locale]/page.tsx — locale from URL segment
interface PageProps {
params: Promise<{ locale: string }>;
}
export default async function HomePage({ params }: PageProps) {
const { locale } = await params;
// Fetch localized content from CMS
const content = await getHomePage(locale);
return <HomePageContent content={content} />;
}
export async function generateStaticParams() {
return [{ locale: "en" }, { locale: "fr" }, { locale: "de" }];
}Set the locale in your CMS client, not in every query. Configure the client once and let all queries inherit the locale context.
7. Type Safety Across the CMS Boundary
CMS data crosses a network boundary. Without validation, a missing field or a renamed content type silently breaks your UI in production.
Always validate CMS responses at the boundary:
import { z } from "zod";
const PostSchema = z.object({
_id: z.string(),
title: z.string(),
slug: z.string(),
publishedAt: z.string().datetime(),
excerpt: z.string().nullable(),
body: z.array(z.any()), // Portable Text / Rich Text blocks
author: z.object({
name: z.string(),
image: z.string().url().nullable(),
}),
});
type Post = z.infer<typeof PostSchema>;
export async function getPost(slug: string): Promise<Post | null> {
const raw = await sanityClient.fetch(
`*[_type == "post" && slug.current == $slug][0]{
_id, title, "slug": slug.current, publishedAt, excerpt, body,
"author": author->{ name, "image": image.asset->url }
}`,
{ slug },
);
if (!raw) return null;
return PostSchema.parse(raw);
}For Sanity, use sanity-typegen to auto-generate TypeScript types from your schema. For Payload, types are generated automatically. For Contentful, use contentful-typescript-codegen or the built-in CLI type generation.
LLM Instructions
Sanity
When generating Sanity CMS code:
Project setup:
- Install
sanityandnext-sanitytogether —next-sanityprovides the Next.js integration layer including live preview, image URL builders, and the Visual Editing overlay. - Use
sanity initfor new projects orsanity init --envto add to an existing Next.js project. The project ID and dataset name go in environment variables. - The Sanity Studio is a React application. In Next.js, embed it as a catch-all route at
/studiousingnext-sanity/studio. - Always install
@sanity/image-urlfor building image URLs with transforms.
Schema definition:
- Use
defineTypeanddefineFieldfromsanity— these provide TypeScript inference. - Group related fields with fieldsets. Use
groupfor tab-based organization in the Studio. - Use
validationchains:Rule.required(),Rule.min(),Rule.max(),Rule.unique(). - For rich text, use
blocktype (Portable Text). For code blocks, use@sanity/code-input. - Reference other documents with
type: "reference"and specifyto: [{ type: "author" }].
Querying:
- Use GROQ for all queries. GROQ is Sanity's native query language and is more powerful than GraphQL for content queries.
- Always project only the fields you need:
{ title, "slug": slug.current, body }— never fetch entire documents. - Dereference references inline:
"author": author->{ name, image }. - Use
next-sanity'ssanityFetchwrapper with{ next: { tags: [...] } }for ISR tag-based revalidation.
Preview and live editing:
- Use
next-sanity'sdefineLivefor real-time live previews. This sets up a live subscription that updates the page as editors type. - Enable Visual Editing with
@sanity/visual-editingfor click-to-edit overlays in preview mode. - Always implement both draft mode (for editors) and published mode (for visitors).
Environment variables:
NEXT_PUBLIC_SANITY_PROJECT_ID— public, used by Studio and clientNEXT_PUBLIC_SANITY_DATASET— public, typically "production"SANITY_API_READ_TOKEN— secret, server-only, for fetching draft contentSANITY_REVALIDATE_SECRET— secret, for webhook revalidation
Payload CMS
When generating Payload CMS code:
Project setup:
- Payload 3.x is built directly on Next.js — it is not a separate server. Install it into an existing Next.js project or use
create-payload-app. - The config file is
payload.config.tsat the project root. It defines collections, globals, plugins, and database adapter. - Payload supports PostgreSQL (via
@payloadcms/db-postgres) and MongoDB (via@payloadcms/db-mongodb). Prefer PostgreSQL for new projects. - The admin panel is served at
/adminby default. It is a full Next.js route.
Collection definitions:
- Collections are the core content types. Each collection has a
slug,fieldsarray, and optionalaccess,hooks,adminconfiguration. - Field types:
text,textarea,richText(Lexical editor),number,select,radio,checkbox,date,upload,relationship,array,group,blocks,tabs,row,collapsible,json,email,point. - Use
relationshipfields to link between collections. SethasMany: truefor one-to-many. - Use
blocksfields for flexible page builder patterns where editors choose from predefined block types. - Globals are singleton documents (site settings, navigation, footer).
Access control:
- Every collection has an
accessobject with functions forcreate,read,update,delete. - Access functions receive
({ req }) => boolean | Where— returntrueto allow,falseto deny, or aWherequery to filter results. - Always define access control. The default is open access, which is dangerous in production.
- Use
req.userto implement role-based access.
Hooks:
beforeChange,afterChange,beforeRead,afterRead,beforeDelete,afterDelete.- Use hooks for side effects: sending emails, invalidating caches, syncing to external services.
- Use
afterChangehooks to trigger Next.js revalidation viarevalidateTag().
Data fetching in Next.js:
- Import
getPayloadfrompayloadand call it with your config to get a typed Payload instance. - Use
payload.find(),payload.findByID(),payload.create(),payload.update(),payload.delete(). - All queries return fully typed results based on your collection definitions.
- For frontend pages, use Server Components and call Payload directly — no API layer needed.
Environment variables:
DATABASE_URI— PostgreSQL or MongoDB connection stringPAYLOAD_SECRET— secret key for encrypting Payload tokensNEXT_PUBLIC_SERVER_URL— the public URL of your app (used for live preview)
Contentful
When generating Contentful code:
Project setup:
- Install
contentfulfor the Delivery API and@contentful/rich-text-react-rendererfor rendering rich text. - Create a Space and Environment in the Contentful web UI. Content models are defined in the UI, not in code (unlike Sanity and Payload).
- Use the Content Delivery API for published content and the Content Preview API for drafts.
- Install
contentful-managementonly if you need to programmatically create or modify content models.
Content model:
- Define content types in the Contentful web UI with fields: Short text, Long text, Rich text, Integer, Decimal, Date, Location, Media, Boolean, JSON, Reference.
- Use "Reference" fields to link between content types. Configure validation to restrict which content types can be referenced.
- Use "Media" fields for images and files. Contentful hosts them on
images.ctfassets.net.
Client setup:
- Create two clients: one for published content (Delivery API,
contentful.createClient) and one for preview content (Preview API,contentful.createClientwithhost: "preview.contentful.com"). - Use the
includeparameter to control reference depth (default is 1 level). - For GraphQL, use the GraphQL Content API endpoint:
https://graphql.contentful.com/content/v1/spaces/{spaceId}.
Rich text rendering:
- Use
documentToReactComponentsfrom@contentful/rich-text-react-rendererto render rich text fields. - Always provide custom
renderNodeoptions for embedded assets and entries — the default renderer does not handle them. - Map
BLOCKS.EMBEDDED_ASSETto your<Image>component andBLOCKS.EMBEDDED_ENTRYto your custom components.
Webhook-triggered ISR:
- Configure a webhook in Contentful Settings that fires on Entry publish/unpublish/delete.
- Point it to your
/api/revalidateendpoint with a secret header. - Use tag-based revalidation: tag each
fetchwith the content type, then revalidate the tag when the webhook fires.
Environment variables:
CONTENTFUL_SPACE_ID— your Contentful space identifierCONTENTFUL_ACCESS_TOKEN— Delivery API token (published content)CONTENTFUL_PREVIEW_TOKEN— Preview API token (draft content)CONTENTFUL_MANAGEMENT_TOKEN— Management API token (only if modifying content models)CONTENTFUL_REVALIDATE_SECRET— secret for webhook revalidationCONTENTFUL_ENVIRONMENT— typically "master"
Examples
Sanity: Full Setup with Next.js App Router
Installation and Project Init
# Install Sanity and Next.js integration
npm install sanity next-sanity @sanity/image-url @sanity/vision @sanity/visual-editing
# Initialize a Sanity project (if you don't have one yet)
npx sanity@latest init --envEnvironment Variables
# .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"
SANITY_API_READ_TOKEN="sk..."
SANITY_REVALIDATE_SECRET="your-webhook-secret"Sanity Client Configuration
// 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!,
apiVersion: "2025-05-01", // Use a locked API version, not "vX"
useCdn: true, // Enable CDN for published content reads
});// lib/sanity/image.ts
import createImageUrlBuilder from "@sanity/image-url";
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { client } from "./client";
const builder = createImageUrlBuilder(client);
export function urlFor(source: SanityImageSource) {
return builder.image(source);
}// lib/sanity/fetch.ts
import { client } from "./client";
import type { QueryParams } from "next-sanity";
// Wrapper for all Sanity fetches — handles token + caching tags
export async function sanityFetch<T>({
query,
params = {},
tags = [],
}: {
query: string;
params?: QueryParams;
tags?: string[];
}): Promise<T> {
return client.fetch<T>(query, params, {
token: process.env.SANITY_API_READ_TOKEN,
perspective: "published",
next: {
tags,
revalidate: 60, // Fallback: revalidate every 60s if webhook fails
},
});
}Schema Definition
// sanity/schemas/post.ts
import { defineType, defineField, defineArrayMember } from "sanity";
export const postType = defineType({
name: "post",
title: "Blog Post",
type: "document",
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
validation: (rule) => rule.required().min(10).max(120),
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: { source: "title", maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: "author",
title: "Author",
type: "reference",
to: [{ type: "author" }],
validation: (rule) => rule.required(),
}),
defineField({
name: "mainImage",
title: "Main Image",
type: "image",
options: { hotspot: true }, // Enable hotspot cropping
fields: [
defineField({
name: "alt",
title: "Alt Text",
type: "string",
validation: (rule) => rule.required(),
}),
],
}),
defineField({
name: "categories",
title: "Categories",
type: "array",
of: [defineArrayMember({ type: "reference", to: [{ type: "category" }] })],
}),
defineField({
name: "publishedAt",
title: "Published At",
type: "datetime",
initialValue: () => new Date().toISOString(),
}),
defineField({
name: "excerpt",
title: "Excerpt",
type: "text",
rows: 3,
validation: (rule) => rule.max(300),
}),
defineField({
name: "body",
title: "Body",
type: "blockContent", // Custom Portable Text definition
}),
],
orderings: [
{
title: "Published Date, New",
name: "publishedAtDesc",
by: [{ field: "publishedAt", direction: "desc" }],
},
],
preview: {
select: {
title: "title",
author: "author.name",
media: "mainImage",
},
prepare({ title, author, media }) {
return {
title,
subtitle: author ? `by ${author}` : "No author",
media,
};
},
},
});// sanity/schemas/author.ts
import { defineType, defineField } from "sanity";
export const authorType = defineType({
name: "author",
title: "Author",
type: "document",
fields: [
defineField({
name: "name",
title: "Name",
type: "string",
validation: (rule) => rule.required(),
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: { source: "name" },
validation: (rule) => rule.required(),
}),
defineField({
name: "image",
title: "Image",
type: "image",
options: { hotspot: true },
}),
defineField({
name: "bio",
title: "Bio",
type: "text",
rows: 4,
}),
],
});// sanity/schemas/blockContent.ts
import { defineType, defineArrayMember } from "sanity";
export const blockContentType = defineType({
name: "blockContent",
title: "Block Content",
type: "array",
of: [
defineArrayMember({
type: "block",
styles: [
{ title: "Normal", value: "normal" },
{ title: "H2", value: "h2" },
{ title: "H3", value: "h3" },
{ title: "H4", value: "h4" },
{ title: "Quote", value: "blockquote" },
],
marks: {
decorators: [
{ title: "Bold", value: "strong" },
{ title: "Italic", value: "em" },
{ title: "Code", value: "code" },
{ title: "Strikethrough", value: "strike-through" },
],
annotations: [
{
name: "link",
type: "object",
title: "Link",
fields: [
{
name: "href",
type: "url",
title: "URL",
validation: (rule) =>
rule.uri({ scheme: ["http", "https", "mailto", "tel"] }),
},
{
name: "openInNewTab",
type: "boolean",
title: "Open in new tab",
initialValue: false,
},
],
},
],
},
}),
defineArrayMember({
type: "image",
options: { hotspot: true },
fields: [
{
name: "alt",
type: "string",
title: "Alt Text",
validation: (rule) => rule.required(),
},
{
name: "caption",
type: "string",
title: "Caption",
},
],
}),
],
});// sanity/schema.ts — register all schemas
import { type SchemaTypeDefinition } from "sanity";
import { postType } from "./schemas/post";
import { authorType } from "./schemas/author";
import { blockContentType } from "./schemas/blockContent";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [postType, authorType, blockContentType],
};Sanity Studio Embedded in Next.js
// sanity.config.ts (project root)
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { visionTool } from "@sanity/vision";
import { schema } from "./sanity/schema";
export default defineConfig({
name: "default",
title: "My Blog",
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [
structureTool(),
visionTool({ defaultApiVersion: "2025-05-01" }), // GROQ playground
],
schema,
});// app/studio/[[...tool]]/page.tsx
"use client";
import { NextStudio } from "next-sanity/studio";
import config from "@/sanity.config";
export default function StudioPage() {
return <NextStudio config={config} />;
}GROQ Queries and Data Fetching
// lib/sanity/queries.ts
import { defineQuery } from "next-sanity";
// List all posts — projected for listing pages
export const POSTS_QUERY = defineQuery(`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
excerpt,
mainImage {
asset-> {
_id,
url,
metadata { dimensions, lqip }
},
alt
},
"author": author-> { name, "slug": slug.current }
}
`);
// Single post by slug — full content
export const POST_BY_SLUG_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current,
publishedAt,
excerpt,
body,
mainImage {
asset-> {
_id,
url,
metadata { dimensions, lqip }
},
alt
},
"author": author-> {
name,
"slug": slug.current,
image { asset-> { url } },
bio
},
"categories": categories[]-> { _id, title, "slug": slug.current }
}
`);
// All post slugs — for generateStaticParams
export const POST_SLUGS_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)]{ "slug": slug.current }
`);// app/blog/page.tsx
import { sanityFetch } from "@/lib/sanity/fetch";
import { POSTS_QUERY } from "@/lib/sanity/queries";
import { urlFor } from "@/lib/sanity/image";
import Image from "next/image";
import Link from "next/link";
export default async function BlogPage() {
const posts = await sanityFetch<Awaited<ReturnType<typeof POSTS_QUERY>>>({
query: POSTS_QUERY,
tags: ["posts"],
});
return (
<main className="mx-auto max-w-4xl px-4 py-12">
<h1 className="mb-8 text-4xl font-bold">Blog</h1>
<div className="grid gap-8">
{posts.map((post) => (
<Link key={post._id} href={`/blog/${post.slug}`} className="group">
<article className="flex gap-6">
{post.mainImage?.asset && (
<Image
src={urlFor(post.mainImage).width(300).height(200).url()}
alt={post.mainImage.alt ?? ""}
width={300}
height={200}
className="rounded-lg object-cover"
placeholder="blur"
blurDataURL={post.mainImage.asset.metadata?.lqip ?? undefined}
/>
)}
<div>
<h2 className="text-xl font-semibold group-hover:underline">
{post.title}
</h2>
{post.excerpt && (
<p className="mt-2 text-gray-600">{post.excerpt}</p>
)}
<p className="mt-2 text-sm text-gray-400">
{post.author?.name} ·{" "}
{new Date(post.publishedAt).toLocaleDateString()}
</p>
</div>
</article>
</Link>
))}
</div>
</main>
);
}// app/blog/[slug]/page.tsx
import { sanityFetch } from "@/lib/sanity/fetch";
import { POST_BY_SLUG_QUERY, POST_SLUGS_QUERY } from "@/lib/sanity/queries";
import { PortableText } from "next-sanity";
import { urlFor } from "@/lib/sanity/image";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const slugs = await sanityFetch<{ slug: string }[]>({
query: POST_SLUGS_QUERY,
tags: ["posts"],
});
return slugs.map(({ slug }) => ({ slug }));
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = await sanityFetch<any>({
query: POST_BY_SLUG_QUERY,
params: { slug },
tags: ["posts", `post-${slug}`],
});
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt ?? undefined,
images: post.mainImage?.asset?.url
? [{ url: urlFor(post.mainImage).width(1200).height(630).url() }]
: [],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const post = await sanityFetch<any>({
query: POST_BY_SLUG_QUERY,
params: { slug },
tags: ["posts", `post-${slug}`],
});
if (!post) notFound();
return (
<article className="mx-auto max-w-3xl px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
<div className="mt-4 flex items-center gap-3 text-gray-600">
{post.author?.image?.asset && (
<Image
src={urlFor(post.author.image).width(40).height(40).url()}
alt={post.author.name}
width={40}
height={40}
className="rounded-full"
/>
)}
<span>{post.author?.name}</span>
<span>·</span>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}
</time>
</div>
</header>
{post.mainImage?.asset && (
<Image
src={urlFor(post.mainImage).width(1200).height(630).url()}
alt={post.mainImage.alt ?? ""}
width={1200}
height={630}
className="mb-8 rounded-xl"
priority
/>
)}
<div className="prose prose-lg max-w-none">
<PortableText value={post.body} />
</div>
</article>
);
}Webhook Revalidation for Sanity
// app/api/revalidate/sanity/route.ts
import { revalidateTag } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
import { parseBody } from "next-sanity/webhook";
export async function POST(request: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<{
_type: string;
slug?: { current?: string };
}>(request, process.env.SANITY_REVALIDATE_SECRET);
if (!isValidSignature) {
return NextResponse.json(
{ message: "Invalid signature" },
{ status: 401 },
);
}
if (!body?._type) {
return NextResponse.json(
{ message: "Missing document type" },
{ status: 400 },
);
}
// Tag-based revalidation
revalidateTag(body._type === "post" ? "posts" : body._type);
if (body.slug?.current) {
revalidateTag(`post-${body.slug.current}`);
}
return NextResponse.json({
revalidated: true,
now: Date.now(),
type: body._type,
});
} catch (error) {
return NextResponse.json(
{ message: "Error revalidating" },
{ status: 500 },
);
}
}Payload CMS: Full Setup with Next.js App Router
Installation and Project Init
# Create a new Payload + Next.js project
npx create-payload-app@latest my-project
# Or add Payload to an existing Next.js project
npm install payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical sharpEnvironment Variables
# .env
DATABASE_URI="postgresql://user:password@localhost:5432/mydb"
PAYLOAD_SECRET="your-long-random-secret-at-least-32-chars"
NEXT_PUBLIC_SERVER_URL="http://localhost:3000"Payload Configuration
// payload.config.ts
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import sharp from "sharp";
import path from "path";
import { fileURLToPath } from "url";
// Collections
import { Users } from "./collections/Users";
import { Posts } from "./collections/Posts";
import { Authors } from "./collections/Authors";
import { Media } from "./collections/Media";
import { Pages } from "./collections/Pages";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
// Admin panel configuration
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
// Collections (content types)
collections: [Users, Posts, Authors, Media, Pages],
// Rich text editor
editor: lexicalEditor(),
// Database adapter
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URI! },
}),
// Image processing
sharp,
// Secret for encrypting tokens
secret: process.env.PAYLOAD_SECRET!,
// TypeScript output path for auto-generated types
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
});Collection Definitions
// collections/Users.ts
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
auth: true, // Enables authentication (login, register, JWT)
admin: {
useAsTitle: "email",
},
fields: [
{
name: "role",
type: "select",
required: true,
defaultValue: "editor",
options: [
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Author", value: "author" },
],
},
{
name: "name",
type: "text",
required: true,
},
],
};// collections/Media.ts
import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = {
slug: "media",
upload: {
staticDir: "media", // Where files are stored on disk
imageSizes: [
{ name: "thumbnail", width: 400, height: 300, position: "centre" },
{ name: "card", width: 768, height: 512, position: "centre" },
{ name: "hero", width: 1920, height: 1080, position: "centre" },
],
adminThumbnail: "thumbnail",
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/svg+xml"],
},
access: {
read: () => true, // Public read access for images
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
{
name: "caption",
type: "text",
},
],
};// collections/Authors.ts
import type { CollectionConfig } from "payload";
export const Authors: CollectionConfig = {
slug: "authors",
admin: {
useAsTitle: "name",
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
description: "URL-friendly identifier. Example: jane-doe",
},
},
{
name: "avatar",
type: "upload",
relationTo: "media",
},
{
name: "bio",
type: "textarea",
},
],
};// collections/Posts.ts
import type { CollectionConfig } from "payload";
import { revalidateTag } from "next/cache";
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "author", "status", "publishedAt"],
},
versions: {
drafts: {
autosave: true, // Enable draft autosaving
},
},
access: {
// Anyone can read published posts
read: ({ req }) => {
if (req.user) return true; // Logged-in users see all posts (including drafts)
return { _status: { equals: "published" } }; // Public sees only published
},
// Only admins and editors can create/update/delete
create: ({ req }) => {
if (!req.user) return false;
return ["admin", "editor"].includes(req.user.role);
},
update: ({ req }) => {
if (!req.user) return false;
if (req.user.role === "admin") return true;
// Authors can only update their own posts
if (req.user.role === "author") {
return { author: { equals: req.user.id } };
}
return ["editor"].includes(req.user.role);
},
delete: ({ req }) => {
return req.user?.role === "admin";
},
},
hooks: {
afterChange: [
({ doc }) => {
// Revalidate Next.js cache when a post changes
revalidateTag("posts");
if (doc.slug) revalidateTag(`post-${doc.slug}`);
},
],
},
fields: [
{
name: "title",
type: "text",
required: true,
minLength: 10,
maxLength: 120,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
admin: {
position: "sidebar",
},
},
{
name: "author",
type: "relationship",
relationTo: "authors",
required: true,
},
{
name: "featuredImage",
type: "upload",
relationTo: "media",
},
{
name: "excerpt",
type: "textarea",
maxLength: 300,
},
{
name: "content",
type: "richText", // Uses Lexical editor
required: true,
},
{
name: "categories",
type: "relationship",
relationTo: "categories",
hasMany: true,
},
{
name: "publishedAt",
type: "date",
admin: {
position: "sidebar",
date: { pickerAppearance: "dayAndTime" },
},
},
{
name: "seo",
type: "group",
fields: [
{ name: "metaTitle", type: "text", maxLength: 60 },
{ name: "metaDescription", type: "textarea", maxLength: 160 },
{ name: "ogImage", type: "upload", relationTo: "media" },
],
},
],
};// collections/Pages.ts — flexible page builder with blocks
import type { CollectionConfig } from "payload";
export const Pages: CollectionConfig = {
slug: "pages",
admin: {
useAsTitle: "title",
},
versions: { drafts: true },
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
unique: true,
},
{
name: "layout",
type: "blocks",
required: true,
blocks: [
// Hero block
{
slug: "hero",
fields: [
{ name: "heading", type: "text", required: true },
{ name: "subheading", type: "textarea" },
{ name: "backgroundImage", type: "upload", relationTo: "media" },
{ name: "ctaText", type: "text" },
{ name: "ctaLink", type: "text" },
],
},
// Feature grid block
{
slug: "featureGrid",
fields: [
{ name: "heading", type: "text" },
{
name: "features",
type: "array",
fields: [
{ name: "title", type: "text", required: true },
{ name: "description", type: "textarea", required: true },
{ name: "icon", type: "text" }, // Icon name from your icon library
],
},
],
},
// Rich text block
{
slug: "richText",
fields: [
{ name: "content", type: "richText", required: true },
],
},
// CTA block
{
slug: "cta",
fields: [
{ name: "heading", type: "text", required: true },
{ name: "description", type: "textarea" },
{ name: "buttonText", type: "text", required: true },
{ name: "buttonLink", type: "text", required: true },
],
},
],
},
],
};Data Fetching in Next.js with Payload
// lib/payload/get-payload.ts
import configPromise from "@payload-config";
import { getPayload as getPayloadInstance } from "payload";
export async function getPayload() {
return getPayloadInstance({ config: configPromise });
}// app/(frontend)/blog/page.tsx
import { getPayload } from "@/lib/payload/get-payload";
import Image from "next/image";
import Link from "next/link";
export const dynamic = "force-static";
export const revalidate = 60;
export default async function BlogPage() {
const payload = await getPayload();
const { docs: posts } = await payload.find({
collection: "posts",
where: { _status: { equals: "published" } },
sort: "-publishedAt",
limit: 20,
depth: 1, // Populate one level of relationships (author, featuredImage)
});
return (
<main className="mx-auto max-w-4xl px-4 py-12">
<h1 className="mb-8 text-4xl font-bold">Blog</h1>
<div className="grid gap-8">
{posts.map((post) => (
<Link key={post.id} href={`/blog/${post.slug}`} className="group">
<article className="flex gap-6">
{typeof post.featuredImage === "object" && post.featuredImage?.url && (
<Image
src={post.featuredImage.url}
alt={post.featuredImage.alt}
width={300}
height={200}
className="rounded-lg object-cover"
/>
)}
<div>
<h2 className="text-xl font-semibold group-hover:underline">
{post.title}
</h2>
{post.excerpt && (
<p className="mt-2 text-gray-600">{post.excerpt}</p>
)}
<p className="mt-2 text-sm text-gray-400">
{typeof post.author === "object" && post.author?.name}
</p>
</div>
</article>
</Link>
))}
</div>
</main>
);
}// app/(frontend)/blog/[slug]/page.tsx
import { getPayload } from "@/lib/payload/get-payload";
import { RichText } from "@payloadcms/richtext-lexical/react";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const payload = await getPayload();
const { docs: posts } = await payload.find({
collection: "posts",
where: { _status: { equals: "published" } },
limit: 1000,
select: { slug: true },
});
return posts.map(({ slug }) => ({ slug }));
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const payload = await getPayload();
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
depth: 1,
});
const post = docs[0];
if (!post) return {};
return {
title: post.seo?.metaTitle ?? post.title,
description: post.seo?.metaDescription ?? post.excerpt,
};
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const payload = await getPayload();
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
depth: 2, // Author + their avatar + featured image
});
const post = docs[0];
if (!post) notFound();
const author = typeof post.author === "object" ? post.author : null;
const featuredImage = typeof post.featuredImage === "object" ? post.featuredImage : null;
return (
<article className="mx-auto max-w-3xl px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
{author && (
<div className="mt-4 flex items-center gap-3 text-gray-600">
{typeof author.avatar === "object" && author.avatar?.url && (
<Image
src={author.avatar.url}
alt={author.name}
width={40}
height={40}
className="rounded-full"
/>
)}
<span>{author.name}</span>
{post.publishedAt && (
<>
<span>·</span>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}
</time>
</>
)}
</div>
)}
</header>
{featuredImage?.url && (
<Image
src={featuredImage.url}
alt={featuredImage.alt}
width={1200}
height={630}
className="mb-8 rounded-xl"
priority
/>
)}
<div className="prose prose-lg max-w-none">
{post.content && <RichText data={post.content} />}
</div>
</article>
);
}Dynamic Page Builder Rendering
// app/(frontend)/[slug]/page.tsx — renders Pages collection with block layout
import { getPayload } from "@/lib/payload/get-payload";
import { notFound } from "next/navigation";
import Image from "next/image";
import { RichText } from "@payloadcms/richtext-lexical/react";
// Block components
function HeroBlock({ block }: { block: any }) {
return (
<section className="relative flex min-h-[60vh] items-center justify-center bg-gray-900 text-white">
{typeof block.backgroundImage === "object" && block.backgroundImage?.url && (
<Image
src={block.backgroundImage.url}
alt=""
fill
className="object-cover opacity-40"
priority
/>
)}
<div className="relative z-10 text-center">
<h1 className="text-5xl font-bold">{block.heading}</h1>
{block.subheading && <p className="mt-4 text-xl">{block.subheading}</p>}
{block.ctaText && block.ctaLink && (
<a
href={block.ctaLink}
className="mt-6 inline-block rounded-lg bg-white px-6 py-3 font-semibold text-gray-900"
>
{block.ctaText}
</a>
)}
</div>
</section>
);
}
function FeatureGridBlock({ block }: { block: any }) {
return (
<section className="px-4 py-16">
{block.heading && (
<h2 className="mb-12 text-center text-3xl font-bold">{block.heading}</h2>
)}
<div className="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{block.features?.map((feature: any, i: number) => (
<div key={i} className="rounded-lg border p-6">
<h3 className="text-lg font-semibold">{feature.title}</h3>
<p className="mt-2 text-gray-600">{feature.description}</p>
</div>
))}
</div>
</section>
);
}
function RichTextBlock({ block }: { block: any }) {
return (
<section className="mx-auto max-w-3xl px-4 py-12">
<div className="prose prose-lg max-w-none">
<RichText data={block.content} />
</div>
</section>
);
}
function CtaBlock({ block }: { block: any }) {
return (
<section className="bg-blue-600 px-4 py-16 text-center text-white">
<h2 className="text-3xl font-bold">{block.heading}</h2>
{block.description && <p className="mt-4 text-lg">{block.description}</p>}
<a
href={block.buttonLink}
className="mt-6 inline-block rounded-lg bg-white px-8 py-3 font-semibold text-blue-600"
>
{block.buttonText}
</a>
</section>
);
}
const blockComponents: Record<string, React.ComponentType<{ block: any }>> = {
hero: HeroBlock,
featureGrid: FeatureGridBlock,
richText: RichTextBlock,
cta: CtaBlock,
};
export default async function DynamicPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const payload = await getPayload();
const { docs } = await payload.find({
collection: "pages",
where: { slug: { equals: slug } },
limit: 1,
depth: 2,
});
const page = docs[0];
if (!page) notFound();
return (
<main>
{page.layout?.map((block: any) => {
const Component = blockComponents[block.blockType];
if (!Component) return null;
return <Component key={block.id} block={block} />;
})}
</main>
);
}Contentful: Full Setup with Next.js App Router
Installation
npm install contentful @contentful/rich-text-react-renderer @contentful/rich-text-typesEnvironment Variables
# .env.local
CONTENTFUL_SPACE_ID="your-space-id"
CONTENTFUL_ACCESS_TOKEN="your-delivery-api-token"
CONTENTFUL_PREVIEW_TOKEN="your-preview-api-token"
CONTENTFUL_ENVIRONMENT="master"
CONTENTFUL_REVALIDATE_SECRET="your-webhook-secret"Contentful Client Setup
// lib/contentful/client.ts
import { createClient, type ContentfulClientApi } from "contentful";
// Published content — uses CDN, cached
function getClient(preview = false): ContentfulClientApi<undefined> {
if (preview) {
return createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: "preview.contentful.com",
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
});
}
return createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
});
}
export const contentfulClient = getClient(false);
export const previewClient = getClient(true);
// Helper: get the right client based on draft mode
export function getContentfulClient(isDraftMode: boolean) {
return isDraftMode ? previewClient : contentfulClient;
}TypeScript Types for Content Model
// lib/contentful/types.ts
import type { Asset, Entry, EntryFields } from "contentful";
// Define types matching your Contentful content model
// These correspond to the content types you create in the Contentful web UI
export interface IBlogPostFields {
title: string;
slug: string;
author: Entry<IAuthorFields>;
featuredImage: Asset;
excerpt: string;
body: EntryFields.RichText;
categories: Entry<ICategoryFields>[];
publishedDate: string; // ISO date
seoTitle?: string;
seoDescription?: string;
}
export interface IAuthorFields {
name: string;
slug: string;
avatar: Asset;
bio: string;
}
export interface ICategoryFields {
name: string;
slug: string;
description?: string;
}
export interface IPageFields {
title: string;
slug: string;
sections: Entry<IHeroFields | IFeatureGridFields | IRichTextFields>[];
}
export interface IHeroFields {
heading: string;
subheading?: string;
backgroundImage: Asset;
ctaText?: string;
ctaLink?: string;
}
export interface IFeatureGridFields {
heading?: string;
features: Entry<IFeatureFields>[];
}
export interface IFeatureFields {
title: string;
description: string;
icon?: string;
}
export interface IRichTextFields {
content: EntryFields.RichText;
}
// Type helpers for entries
export type BlogPostEntry = Entry<IBlogPostFields>;
export type AuthorEntry = Entry<IAuthorFields>;
export type CategoryEntry = Entry<ICategoryFields>;Data Fetching Functions
// lib/contentful/api.ts
import { getContentfulClient } from "./client";
import type { BlogPostEntry, IBlogPostFields } from "./types";
export async function getAllPosts(isDraftMode = false): Promise<BlogPostEntry[]> {
const client = getContentfulClient(isDraftMode);
const entries = await client.getEntries<IBlogPostFields>({
content_type: "blogPost",
order: ["-fields.publishedDate"],
include: 2, // Resolve 2 levels of references (author, categories)
});
return entries.items;
}
export async function getPostBySlug(
slug: string,
isDraftMode = false,
): Promise<BlogPostEntry | null> {
const client = getContentfulClient(isDraftMode);
const entries = await client.getEntries<IBlogPostFields>({
content_type: "blogPost",
"fields.slug": slug,
include: 3, // Deep resolution for embedded entries in rich text
limit: 1,
});
return entries.items[0] ?? null;
}
export async function getAllPostSlugs(): Promise<string[]> {
const client = getContentfulClient(false);
const entries = await client.getEntries<IBlogPostFields>({
content_type: "blogPost",
select: ["fields.slug"],
limit: 1000,
});
return entries.items.map((item) => item.fields.slug);
}
// Localized fetching
export async function getPostBySlugLocalized(
slug: string,
locale: string,
isDraftMode = false,
): Promise<BlogPostEntry | null> {
const client = getContentfulClient(isDraftMode);
const entries = await client.getEntries<IBlogPostFields>({
content_type: "blogPost",
"fields.slug": slug,
locale, // "en-US", "fr-FR", "de-DE"
include: 3,
limit: 1,
});
return entries.items[0] ?? null;
}Rich Text Rendering
// lib/contentful/rich-text.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";
import Link from "next/link";
const renderOptions: Options = {
renderNode: {
// Embedded images within rich text
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const { title, description, file } = node.data.target.fields;
const url = file?.url;
const width = file?.details?.image?.width ?? 800;
const height = file?.details?.image?.height ?? 450;
if (!url) return null;
return (
<figure className="my-8">
<Image
src={`https:${url}`}
alt={description ?? title ?? ""}
width={width}
height={height}
className="rounded-lg"
/>
{title && (
<figcaption className="mt-2 text-center text-sm text-gray-500">
{title}
</figcaption>
)}
</figure>
);
},
// Embedded entries within rich text (e.g., a callout box, code snippet)
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const contentType = node.data.target.sys.contentType?.sys.id;
if (contentType === "callout") {
return (
<aside className="my-6 rounded-lg border-l-4 border-blue-500 bg-blue-50 p-4">
<p className="font-medium">{node.data.target.fields.text}</p>
</aside>
);
}
if (contentType === "codeBlock") {
return (
<pre className="my-6 overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm text-white">
<code>{node.data.target.fields.code}</code>
</pre>
);
}
return null;
},
// Inline entries (e.g., inline mention of another post)
[INLINES.EMBEDDED_ENTRY]: (node) => {
const contentType = node.data.target.sys.contentType?.sys.id;
if (contentType === "blogPost") {
return (
<Link
href={`/blog/${node.data.target.fields.slug}`}
className="text-blue-600 underline"
>
{node.data.target.fields.title}
</Link>
);
}
return null;
},
// Hyperlinks
[INLINES.HYPERLINK]: (node, children) => {
const isExternal = !node.data.uri.startsWith("/");
return (
<a
href={node.data.uri}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="text-blue-600 underline"
>
{children}
</a>
);
},
},
};
export function RichText({ document }: { document: Document }) {
return (
<div className="prose prose-lg max-w-none">
{documentToReactComponents(document, renderOptions)}
</div>
);
}Next.js Pages with Contentful
// app/blog/page.tsx
import { getAllPosts } from "@/lib/contentful/api";
import Image from "next/image";
import Link from "next/link";
export const revalidate = 60; // Fallback revalidation
export default async function BlogPage() {
const posts = await getAllPosts();
return (
<main className="mx-auto max-w-4xl px-4 py-12">
<h1 className="mb-8 text-4xl font-bold">Blog</h1>
<div className="grid gap-8">
{posts.map((post) => {
const { title, slug, excerpt, featuredImage, author, publishedDate } =
post.fields;
return (
<Link key={post.sys.id} href={`/blog/${slug}`} className="group">
<article className="flex gap-6">
{featuredImage?.fields?.file?.url && (
<Image
src={`https:${featuredImage.fields.file.url}?w=300&h=200&fit=fill`}
alt={featuredImage.fields.description ?? title}
width={300}
height={200}
className="rounded-lg object-cover"
/>
)}
<div>
<h2 className="text-xl font-semibold group-hover:underline">
{title}
</h2>
{excerpt && (
<p className="mt-2 text-gray-600">{excerpt}</p>
)}
<p className="mt-2 text-sm text-gray-400">
{author?.fields?.name} ·{" "}
{new Date(publishedDate).toLocaleDateString()}
</p>
</div>
</article>
</Link>
);
})}
</div>
</main>
);
}// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from "@/lib/contentful/api";
import { RichText } from "@/lib/contentful/rich-text";
import Image from "next/image";
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
const { title, seoTitle, seoDescription, excerpt, featuredImage } = post.fields;
return {
title: seoTitle ?? title,
description: seoDescription ?? excerpt,
openGraph: {
title: seoTitle ?? title,
description: seoDescription ?? excerpt,
images: featuredImage?.fields?.file?.url
? [{ url: `https:${featuredImage.fields.file.url}?w=1200&h=630&fit=fill` }]
: [],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const { isEnabled: isDraftMode } = await draftMode();
const post = await getPostBySlug(slug, isDraftMode);
if (!post) notFound();
const { title, body, featuredImage, author, publishedDate } = post.fields;
return (
<article className="mx-auto max-w-3xl px-4 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold">{title}</h1>
<div className="mt-4 flex items-center gap-3 text-gray-600">
{author?.fields?.avatar?.fields?.file?.url && (
<Image
src={`https:${author.fields.avatar.fields.file.url}?w=40&h=40&fit=fill`}
alt={author.fields.name}
width={40}
height={40}
className="rounded-full"
/>
)}
<span>{author?.fields?.name}</span>
<span>·</span>
<time dateTime={publishedDate}>
{new Date(publishedDate).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
})}
</time>
</div>
</header>
{featuredImage?.fields?.file?.url && (
<Image
src={`https:${featuredImage.fields.file.url}?w=1200&h=630&fit=fill`}
alt={featuredImage.fields.description ?? title}
width={1200}
height={630}
className="mb-8 rounded-xl"
priority
/>
)}
<RichText document={body} />
{isDraftMode && (
<div className="fixed bottom-4 right-4 rounded-lg bg-yellow-400 px-4 py-2 text-sm font-medium text-yellow-900 shadow-lg">
Draft Mode{" "}
<a href="/api/draft/disable" className="underline">
Exit
</a>
</div>
)}
</article>
);
}Contentful Webhook Revalidation
// app/api/revalidate/contentful/route.ts
import { revalidateTag } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
// Contentful sends a webhook payload when content is published/unpublished
interface ContentfulWebhookPayload {
sys: {
id: string;
type: string;
contentType?: {
sys: { id: string };
};
};
fields?: Record<string, any>;
}
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-contentful-webhook-secret");
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
}
try {
const body: ContentfulWebhookPayload = await request.json();
const contentType = body.sys.contentType?.sys.id;
if (!contentType) {
return NextResponse.json({ message: "No content type" }, { status: 400 });
}
// Map Contentful content types to cache tags
const tagMap: Record<string, string[]> = {
blogPost: ["posts"],
author: ["posts", "authors"],
category: ["posts", "categories"],
page: ["pages"],
};
const tags = tagMap[contentType] ?? [contentType];
for (const tag of tags) {
revalidateTag(tag);
}
// If the payload includes a slug, revalidate that specific entry
const slug = body.fields?.slug?.["en-US"];
if (slug && contentType === "blogPost") {
revalidateTag(`post-${slug}`);
}
return NextResponse.json({
revalidated: true,
tags,
now: Date.now(),
});
} catch (error) {
return NextResponse.json(
{ message: "Error processing webhook" },
{ status: 500 },
);
}
}Draft Mode for Contentful 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.CONTENTFUL_PREVIEW_TOKEN) {
return new Response("Invalid token", { status: 401 });
}
(await draftMode()).enable();
redirect(slug ? `/blog/${slug}` : "/");
}Common Mistakes
1. Fetching Entire Documents Instead of Projecting Fields
Wrong:
// Sanity: fetches every field including unused ones
const posts = await client.fetch(`*[_type == "post"]`);
// Contentful: resolves 10 levels deep by default
const entries = await client.getEntries({ content_type: "blogPost", include: 10 });Fix: Always project only the fields you need:
// Sanity: explicit projection
const posts = await client.fetch(`
*[_type == "post"] { _id, title, "slug": slug.current, excerpt }
`);
// Contentful: limit include depth
const entries = await client.getEntries({
content_type: "blogPost",
include: 2,
select: ["fields.title", "fields.slug", "fields.excerpt", "sys.id"],
});2. Missing Webhook Signature Validation
Wrong:
// Accepts any request — anyone can trigger revalidation
export async function POST(request: NextRequest) {
const body = await request.json();
revalidateTag(body.type);
return NextResponse.json({ ok: true });
}Fix: Always validate the webhook secret:
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
// ... proceed with revalidation
}3. Hardcoding CMS IDs Instead of Slugs
Wrong:
// Breaks when content is migrated, re-created, or IDs change between environments
const post = await client.fetch(`*[_id == "abc123"][0]`);Fix: Use human-readable slugs as identifiers:
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug: "my-post" },
);4. No Fallback Revalidation When Webhooks Fail
Wrong:
// Relies entirely on webhooks — if the webhook fails, content is stale forever
export const revalidate = false;Fix: Set a reasonable fallback revalidate time alongside webhook-triggered revalidation:
// Pages revalidate every 5 minutes as a safety net, but webhooks trigger instant updates
export const revalidate = 300;5. Not Handling Missing References Gracefully
Wrong:
// Crashes if author was deleted from the CMS
<p>{post.author.name}</p>Fix: Always check if references resolved:
// Sanity: references might be null if the referenced doc was deleted
<p>{post.author?.name ?? "Unknown author"}</p>
// Payload: relationships might be an ID string if depth is too shallow
const authorName = typeof post.author === "object" ? post.author.name : "Unknown";
// Contentful: check that the entry resolved
const authorName = post.fields.author?.fields?.name ?? "Unknown author";6. Using Sanity's vX API Version
Wrong:
const client = createClient({
projectId: "...",
dataset: "production",
apiVersion: "vX", // Unstable — breaking changes without warning
});Fix: Lock the API version to a specific date:
const client = createClient({
projectId: "...",
dataset: "production",
apiVersion: "2025-05-01", // Locked, stable version
});7. Open Access Control in Payload CMS
Wrong:
// Default access is open — anyone can read, create, update, delete
export const Posts: CollectionConfig = {
slug: "posts",
fields: [/* ... */],
// No access property — everything is wide open
};Fix: Always define access control for every collection:
export const Posts: CollectionConfig = {
slug: "posts",
access: {
read: () => true, // Public read is fine for blog posts
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.role === "admin",
},
fields: [/* ... */],
};8. Not Configuring Image Remote Patterns
Wrong:
// Next.js blocks the image because the domain is not whitelisted
<Image src="https://cdn.sanity.io/images/..." alt="" width={800} height={400} />
// Error: Invalid src prop — hostname "cdn.sanity.io" is not configuredFix: Add CMS image domains to next.config.ts:
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.sanity.io" },
{ protocol: "https", hostname: "images.ctfassets.net" },
],
},
};9. Rendering Rich Text Without Custom Node Handlers
Wrong:
// Contentful: embedded images and entries render as nothing
import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
export function Body({ document }: { document: Document }) {
return <>{documentToReactComponents(document)}</>; // No options — embedded assets are invisible
}Fix: Provide renderNode options for all embedded types:
const options: Options = {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const url = node.data.target.fields.file?.url;
if (!url) return null;
return <Image src={`https:${url}`} alt="" width={800} height={450} />;
},
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
// Handle each embedded content type
return <EmbeddedEntry entry={node.data.target} />;
},
},
};10. Mixing Draft and Published Content in the Same Cache
Wrong:
// Same cache key for draft and published — logged-in editors pollute the public cache
const post = await client.fetch(query, params, {
next: { tags: ["posts"] },
// Uses draft token but tags identically to published content
});Fix: Use separate cache strategies for draft and published:
// Published: cached with tags for ISR
const post = await client.fetch(query, params, {
token: process.env.SANITY_API_READ_TOKEN,
perspective: "published",
next: { tags: ["posts", `post-${slug}`] },
});
// Draft: never cached
const draftPost = await client.fetch(query, params, {
token: process.env.SANITY_API_READ_TOKEN,
perspective: "previewDrafts",
next: { revalidate: 0 }, // No caching for drafts
});See also: Frontend/Data-Fetching for server-first data fetching patterns, caching layers, and revalidation strategies | Hosting-Deployment for deploying Next.js with ISR and webhook integration
Last reviewed: 2026-03
By Ryan Lind, Assisted by Claude Code and Google Gemini.
Analytics & Monitoring
Product analytics, error tracking, session replay, feature flags, and privacy-first web analytics — the complete toolkit for understanding what users do, why they churn, and where your code breaks in production.
File Storage & Uploads
Presigned URLs, type-safe uploads, S3-compatible object storage, CDN delivery, access control, and image optimization -- storing and serving user-generated content in Next.js applications.