Sanity is a headless CMS with a real-time collaboration Studio and a GROQ query language for precise data fetching. defineType({ name, type, fields }) defines document schemas in TypeScript config files. GROQ queries project exactly the fields needed: *[_type == "post" && status == "published"]{ title, slug, "preview": body[0...1] }. @sanity/client sends queries via HTTPS or WebSocket listeners. @sanity/image-url builds CDN image URLs with transformations. Portable text handles rich content blocks with custom marks and annotations. next-sanity provides the SanityClient and live() function for draft mode previews. Webhooks trigger Next.js path revalidation. Claude Code generates Sanity schema definitions, GROQ queries, image transforms, Next.js integration with live preview, and the revalidation webhook handlers.
CLAUDE.md for Sanity Studio
## Sanity Stack
- Version: sanity >= 3.60, @sanity/client >= 6.22, next-sanity >= 9
- Schema: defineType + defineField in sanity.config.ts — schemaTypes array
- Studio: sanity.config.ts with projectId, dataset, studio (basePath: "/studio")
- GROQ: *[_type == "post"]{ _id, title, slug, "imageUrl": image.asset->url }
- Image: urlFor(source).width(800).auto("format").url() — @sanity/image-url
- Client: createClient({ projectId, dataset, useCdn: true, apiVersion }) — read-only CDN
- Preview: createClient({ useCdn: false, token: SANITY_API_READ_TOKEN }) — live preview
- Revalidate: webhook → /api/revalidate route verifies signature + calls revalidatePath
Schema Definitions
// sanity/schemas/post.ts — blog post document type
import { defineType, defineField, defineArrayMember } from "sanity"
import { DocumentTextIcon } from "@sanity/icons"
export const postType = defineType({
name: "post",
title: "Blog Post",
type: "document",
icon: DocumentTextIcon,
groups: [
{ name: "content", title: "Content", default: true },
{ name: "seo", title: "SEO" },
{ name: "settings", title: "Settings" },
],
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
group: "content",
validation: rule => rule.required().min(3).max(120),
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
group: "settings",
options: {
source: "title",
maxLength: 80,
slugify: input =>
input.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""),
},
validation: rule => rule.required(),
}),
defineField({
name: "status",
title: "Status",
type: "string",
group: "settings",
options: {
list: [
{ title: "Draft", value: "draft" },
{ title: "Published", value: "published" },
{ title: "Archived", value: "archived" },
],
layout: "radio",
},
initialValue: "draft",
}),
defineField({
name: "publishedAt",
title: "Published At",
type: "datetime",
group: "settings",
hidden: ({ document }) => document?.status !== "published",
}),
defineField({
name: "author",
title: "Author",
type: "reference",
to: [{ type: "author" }],
group: "content",
}),
defineField({
name: "coverImage",
title: "Cover Image",
type: "image",
group: "content",
options: { hotspot: true },
fields: [
defineField({
name: "alt",
title: "Alt Text",
type: "string",
validation: rule => rule.required(),
}),
defineField({
name: "caption",
title: "Caption",
type: "string",
}),
],
}),
defineField({
name: "excerpt",
title: "Excerpt",
type: "text",
group: "content",
rows: 3,
validation: rule => rule.max(280),
}),
defineField({
name: "body",
title: "Body",
type: "array",
group: "content",
of: [
defineArrayMember({ type: "block" }),
defineArrayMember({
type: "image",
options: { hotspot: true },
fields: [
{ name: "alt", type: "string", title: "Alt Text" },
{ name: "caption", type: "string", title: "Caption" },
],
}),
defineArrayMember({
name: "callout",
type: "object",
title: "Callout Box",
fields: [
{ name: "text", type: "text", title: "Text" },
{
name: "tone",
type: "string",
options: {
list: [
{ value: "info", title: "Info" },
{ value: "warning", title: "Warning" },
{ value: "success", title: "Success" },
],
},
},
],
}),
],
}),
// SEO group
defineField({
name: "seoTitle",
title: "SEO Title",
type: "string",
group: "seo",
validation: rule => rule.max(60),
}),
defineField({
name: "seoDescription",
title: "SEO Description",
type: "text",
group: "seo",
rows: 2,
validation: rule => rule.max(160),
}),
],
preview: {
select: {
title: "title",
subtitle: "author.name",
media: "coverImage",
},
},
})
Sanity Config
// sanity.config.ts — Studio configuration
import { defineConfig } from "sanity"
import { structureTool } from "sanity/structure"
import { visionTool } from "@sanity/vision"
import { postType } from "./sanity/schemas/post"
import { authorType } from "./sanity/schemas/author"
import { categoryType } from "./sanity/schemas/category"
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({
structure: S =>
S.list()
.title("Content")
.items([
S.listItem()
.title("Posts")
.child(S.documentList().title("All Posts").filter('_type == "post"')),
S.divider(),
S.listItem()
.title("Authors")
.child(S.documentTypeList("author")),
S.listItem()
.title("Categories")
.child(S.documentTypeList("category")),
]),
}),
visionTool(), // GROQ playground in Studio
],
schema: { types: [postType, authorType, categoryType] },
})
GROQ Queries
// sanity/queries.ts — GROQ query definitions
import { groq } from "next-sanity"
// Published posts list
export const postsQuery = groq`
*[_type == "post" && status == "published"] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
excerpt,
publishedAt,
"coverImage": {
"url": coverImage.asset->url,
"alt": coverImage.alt,
"dimensions": coverImage.asset->metadata.dimensions
},
"author": author->{
name,
"avatarUrl": avatar.asset->url
},
"categories": categories[]->{
title,
"slug": slug.current
}
}
`
// Single post by slug
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug && status == "published"][0] {
_id,
title,
"slug": slug.current,
publishedAt,
body,
"coverImage": {
"url": coverImage.asset->url,
"alt": coverImage.alt,
"blurDataURL": coverImage.asset->metadata.lqip
},
"author": author->{
name, bio,
"avatarUrl": avatar.asset->url
},
seoTitle,
seoDescription
}
`
// All slugs for static generation
export const postSlugsQuery = groq`
*[_type == "post" && status == "published"].slug.current
`
Next.js Integration
// lib/sanity.ts — client setup
import { createClient } from "next-sanity"
import imageUrlBuilder from "@sanity/image-url"
import type { SanityImageSource } from "@sanity/image-url/lib/types/types"
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2024-10-01",
useCdn: true, // CDN for public reads; set false for draft preview
})
const builder = imageUrlBuilder(client)
export function urlFor(source: SanityImageSource) {
return builder.image(source)
}
// app/blog/[slug]/page.tsx — Next.js App Router with Sanity
import { client } from "@/lib/sanity"
import { postBySlugQuery, postSlugsQuery } from "@/sanity/queries"
import { urlFor } from "@/lib/sanity"
import { PortableText } from "@portabletext/react"
import { notFound } from "next/navigation"
import Image from "next/image"
export async function generateStaticParams() {
const slugs = await client.fetch<string[]>(postSlugsQuery)
return slugs.map(slug => ({ slug }))
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await client.fetch(postBySlugQuery, { slug: params.slug })
if (!post) notFound()
return (
<article>
{post.coverImage && (
<Image
src={urlFor(post.coverImage).width(1200).height(630).auto("format").url()}
alt={post.coverImage.alt}
width={1200}
height={630}
placeholder="blur"
blurDataURL={post.coverImage.blurDataURL}
/>
)}
<h1>{post.title}</h1>
<PortableText
value={post.body}
components={{
types: {
image: ({ value }) => (
<figure>
<Image
src={urlFor(value).width(800).auto("format").url()}
alt={value.alt ?? ""}
width={800}
height={450}
/>
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
),
callout: ({ value }) => (
<div className={`callout callout--${value.tone}`}>
<p>{value.text}</p>
</div>
),
},
}}
/>
</article>
)
}
Webhook Revalidation
// app/api/revalidate/route.ts — Sanity webhook handler
import { revalidatePath, revalidateTag } from "next/cache"
import { type NextRequest, NextResponse } from "next/server"
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET!
export async function POST(req: NextRequest) {
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ""
const body = await req.text()
if (!isValidSignature(body, signature, WEBHOOK_SECRET)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 })
}
const { _type, slug } = JSON.parse(body)
if (_type === "post" && slug?.current) {
revalidatePath(`/blog/${slug.current}`)
revalidateTag("posts")
} else if (_type === "post") {
revalidateTag("posts")
}
return NextResponse.json({ revalidated: true })
}
For the Payload CMS alternative that self-hosts — no Sanity cloud dependency, direct database access, TypeScript-first local API, and custom admin UI components — see the Payload CMS guide for collection definitions and Next.js integration. For the Keystatic CMS alternative that stores content as files in git — no database, versioned content history, and perfect for documentation sites where content authors are comfortable with git, the file-based approach complements static site generators. The Claude Skills 360 bundle includes Sanity Studio skill sets covering schema design, GROQ queries, and Next.js integration. Start with the free tier to try Sanity schema generation.