Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add soilmass/vibe-coding-plugin --skill "cms"
Install specific skill from multi-skill repository
# Description
>
# SKILL.md
name: cms
description: >
Headless CMS integration β Contentful/Sanity client, MDX rendering, Draft Mode preview, content caching
allowed-tools: Read, Grep, Glob
CMS
Purpose
Headless CMS integration for Next.js 15 with support for Contentful, Sanity, and MDX content.
Covers type-safe CMS clients, Server Component MDX rendering, Draft Mode for previews, image
optimization, and content caching with tag-based revalidation.
When to Use
- Integrating Contentful or Sanity as headless CMS
- Rendering MDX content in Server Components
- Setting up content preview with Next.js Draft Mode
- Caching CMS content with webhook-triggered revalidation
- Building blog posts, landing pages, or documentation from CMS
When NOT to Use
- Database-driven content β
prisma - Static markdown files without CMS β plain MDX
- API documentation β
api-documentation - SEO metadata β
nextjs-metadata
Pattern
Contentful client setup
// src/lib/contentful.ts
import "server-only";
import { createClient } from "contentful";
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
});
const previewClient = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
host: "preview.contentful.com",
});
export function getClient(preview = false) {
return preview ? previewClient : client;
}
Sanity client setup
// src/lib/sanity.ts
import "server-only";
import { createClient } from "@sanity/client";
export const sanityClient = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET ?? "production",
apiVersion: "2024-01-01",
useCdn: process.env.NODE_ENV === "production",
token: process.env.SANITY_API_TOKEN, // Only for authenticated queries
});
export const previewClient = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET ?? "production",
apiVersion: "2024-01-01",
useCdn: false,
token: process.env.SANITY_API_TOKEN,
perspective: "previewDrafts",
});
Type-safe content fetching
// src/lib/content.ts
import "server-only";
import { z } from "zod";
import { getClient } from "@/lib/contentful";
import { cache } from "react";
const BlogPostSchema = z.object({
title: z.string(),
slug: z.string(),
body: z.string(),
publishedAt: z.string().datetime(),
author: z.object({ name: z.string(), avatar: z.string().url().optional() }),
});
type BlogPost = z.infer<typeof BlogPostSchema>;
export const getBlogPosts = cache(async (preview = false): Promise<BlogPost[]> => {
const client = getClient(preview);
const entries = await client.getEntries({ content_type: "blogPost", order: ["-sys.createdAt"] });
return entries.items.map((item) =>
BlogPostSchema.parse({
title: item.fields.title,
slug: item.fields.slug,
body: item.fields.body,
publishedAt: item.sys.createdAt,
author: item.fields.author,
})
);
});
export const getBlogPost = cache(async (slug: string, preview = false) => {
const client = getClient(preview);
const entries = await client.getEntries({
content_type: "blogPost",
"fields.slug": slug,
limit: 1,
});
if (!entries.items[0]) return null;
return BlogPostSchema.parse(entries.items[0].fields);
});
MDX rendering in Server Components
// src/components/mdx-content.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { Callout } from "@/components/ui/callout";
import { CodeBlock } from "@/components/ui/code-block";
const components = {
Callout,
CodeBlock,
img: (props: React.ComponentProps<"img">) => (
<img {...props} className="rounded-lg" loading="lazy" />
),
};
export function MDXContent({ source }: { source: string }) {
return <MDXRemote source={source} components={components} />;
}
Draft Mode preview
// src/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_SECRET) {
return new Response("Invalid secret", { status: 401 });
}
const draft = await draftMode();
draft.enable();
redirect(`/blog/${slug}`);
}
Blog page with draft support
// src/app/blog/[slug]/page.tsx
import { draftMode } from "next/headers";
import { getBlogPost } from "@/lib/content";
import { MDXContent } from "@/components/mdx-content";
import { notFound } from "next/navigation";
export default async function BlogPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { isEnabled: preview } = await draftMode();
const post = await getBlogPost(slug, preview);
if (!post) notFound();
return (
<article className="prose dark:prose-invert max-w-2xl mx-auto">
<h1>{post.title}</h1>
<MDXContent source={post.body} />
</article>
);
}
CMS webhook revalidation
// src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-webhook-secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
revalidateTag("cms-content");
return NextResponse.json({ revalidated: true });
}
Static generation for CMS pages
// src/app/blog/[slug]/page.tsx (add)
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Anti-pattern
Fetching CMS data in Client Components
CMS data should be fetched in Server Components. Client-side fetching exposes API keys,
adds latency, and bypasses caching. Pass rendered content as props to Client Components
when interactivity is needed.
No cache invalidation strategy
Without webhook-based revalidation, content updates require a full redeploy. Always set
up CMS webhooks that call your revalidation endpoint.
Common Mistakes
- Contentful Preview API in production β only use for Draft Mode
- Not validating CMS data with Zod β CMS schemas can change
- Missing
generateStaticParamsβ pages not pre-rendered next-mdx-remotev4 in Server Components β use/rscimport- No fallback for missing CMS entries β use
notFound()
Checklist
- [ ] CMS client configured with type-safe queries
- [ ] Content validated with Zod schemas
- [ ] MDX rendering with custom components
- [ ] Draft Mode for content preview
- [ ] Webhook endpoint for cache revalidation
- [ ]
generateStaticParamsfor static generation - [ ] CMS images through
next/imageloader
Composes With
nextjs-dataβ caching strategies for CMS contentnextjs-metadataβ dynamic metadata from CMS fieldscachingβ tag-based revalidation on CMS webhookimage-optimizationβ CMS image optimization via next/imagereact-server-componentsβ server-side MDX renderingseo-advancedβ CMS content SEO
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.