Vibe Code Bible
Tools

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

FeatureSanityPayload CMSContentful
Pricing (free tier)Free (3 users, 500k API CDN requests/mo)Free & open-source (self-hosted)Free (1 space, 5 users, 25k records)
Open-sourceStudio is open-source, backend is hostedFully open-source (MIT)No
Self-hostableNo (hosted backend + self-hosted Studio)Yes (your server, your database)No
Query languageGROQ (custom) + GraphQLLocal DB queries (Drizzle/Mongoose)GraphQL + REST
Real-time previewExcellent (native live preview via next-sanity)Good (built-in draft preview)Good (Preview API + webhooks)
TypeScript nativeStrong (schema codegen, typed GROQ)Excellent (built in TypeScript from ground up)Moderate (codegen available, SDK typed)
LocalizationBuilt-in field-level + document-levelPlugin-based or field-levelEnterprise-grade built-in
Media managementSanity CDN with on-the-fly transformsLocal/S3 uploads with image resizingBuilt-in DAM with CDN
Best forMost projects, blogs, marketing sites, appsFull control, self-hosted, complex access controlEnterprise 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:

  1. The CMS provides a preview URL that includes a secret token
  2. A Next.js API route validates the token and enables Draft Mode via draftMode().enable()
  3. Data-fetching functions check draftMode().isEnabled and fetch draft content when true
  4. 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:

  1. Editor publishes content in the CMS
  2. CMS fires a webhook to your Next.js revalidation endpoint
  3. The endpoint validates the webhook signature and calls revalidateTag() or revalidatePath()
  4. 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.remotePatterns in next.config.ts to 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 sanity and next-sanity together — next-sanity provides the Next.js integration layer including live preview, image URL builders, and the Visual Editing overlay.
  • Use sanity init for new projects or sanity init --env to 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 /studio using next-sanity/studio.
  • Always install @sanity/image-url for building image URLs with transforms.

Schema definition:

  • Use defineType and defineField from sanity — these provide TypeScript inference.
  • Group related fields with fieldsets. Use group for tab-based organization in the Studio.
  • Use validation chains: Rule.required(), Rule.min(), Rule.max(), Rule.unique().
  • For rich text, use block type (Portable Text). For code blocks, use @sanity/code-input.
  • Reference other documents with type: "reference" and specify to: [{ 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's sanityFetch wrapper with { next: { tags: [...] } } for ISR tag-based revalidation.

Preview and live editing:

  • Use next-sanity's defineLive for real-time live previews. This sets up a live subscription that updates the page as editors type.
  • Enable Visual Editing with @sanity/visual-editing for 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 client
  • NEXT_PUBLIC_SANITY_DATASET — public, typically "production"
  • SANITY_API_READ_TOKEN — secret, server-only, for fetching draft content
  • SANITY_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.ts at 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 /admin by default. It is a full Next.js route.

Collection definitions:

  • Collections are the core content types. Each collection has a slug, fields array, and optional access, hooks, admin configuration.
  • Field types: text, textarea, richText (Lexical editor), number, select, radio, checkbox, date, upload, relationship, array, group, blocks, tabs, row, collapsible, json, email, point.
  • Use relationship fields to link between collections. Set hasMany: true for one-to-many.
  • Use blocks fields 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 access object with functions for create, read, update, delete.
  • Access functions receive ({ req }) => boolean | Where — return true to allow, false to deny, or a Where query to filter results.
  • Always define access control. The default is open access, which is dangerous in production.
  • Use req.user to 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 afterChange hooks to trigger Next.js revalidation via revalidateTag().

Data fetching in Next.js:

  • Import getPayload from payload and 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 string
  • PAYLOAD_SECRET — secret key for encrypting Payload tokens
  • NEXT_PUBLIC_SERVER_URL — the public URL of your app (used for live preview)

Contentful

When generating Contentful code:

Project setup:

  • Install contentful for the Delivery API and @contentful/rich-text-react-renderer for 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-management only 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.createClient with host: "preview.contentful.com").
  • Use the include parameter 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 documentToReactComponents from @contentful/rich-text-react-renderer to render rich text fields.
  • Always provide custom renderNode options for embedded assets and entries — the default renderer does not handle them.
  • Map BLOCKS.EMBEDDED_ASSET to your <Image> component and BLOCKS.EMBEDDED_ENTRY to your custom components.

Webhook-triggered ISR:

  • Configure a webhook in Contentful Settings that fires on Entry publish/unpublish/delete.
  • Point it to your /api/revalidate endpoint with a secret header.
  • Use tag-based revalidation: tag each fetch with the content type, then revalidate the tag when the webhook fires.

Environment variables:

  • CONTENTFUL_SPACE_ID — your Contentful space identifier
  • CONTENTFUL_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 revalidation
  • CONTENTFUL_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 --env

Environment 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} &middot;{" "}
                  {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>&middot;</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 sharp

Environment 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>&middot;</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-types

Environment 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} &middot;{" "}
                    {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>&middot;</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 configured

Fix: 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.

On this page

Content Management SystemsWhen to Use WhatPrinciples1. When to Use a CMS (vs a Database)2. Content Modeling Best Practices3. Preview and Draft Mode in Next.js4. Webhook-Triggered Rebuilds and On-Demand Revalidation5. Image and Asset Handling6. Localization Considerations7. Type Safety Across the CMS BoundaryLLM InstructionsSanityPayload CMSContentfulExamplesSanity: Full Setup with Next.js App RouterInstallation and Project InitEnvironment VariablesSanity Client ConfigurationSchema DefinitionSanity Studio Embedded in Next.jsGROQ Queries and Data FetchingWebhook Revalidation for SanityPayload CMS: Full Setup with Next.js App RouterInstallation and Project InitEnvironment VariablesPayload ConfigurationCollection DefinitionsData Fetching in Next.js with PayloadDynamic Page Builder RenderingContentful: Full Setup with Next.js App RouterInstallationEnvironment VariablesContentful Client SetupTypeScript Types for Content ModelData Fetching FunctionsRich Text RenderingNext.js Pages with ContentfulContentful Webhook RevalidationDraft Mode for Contentful PreviewCommon Mistakes1. Fetching Entire Documents Instead of Projecting Fields2. Missing Webhook Signature Validation3. Hardcoding CMS IDs Instead of Slugs4. No Fallback Revalidation When Webhooks Fail5. Not Handling Missing References Gracefully6. Using Sanity's vX API Version7. Open Access Control in Payload CMS8. Not Configuring Image Remote Patterns9. Rendering Rich Text Without Custom Node Handlers10. Mixing Draft and Published Content in the Same Cache