Claude Code for Sanity Studio: Headless CMS with GROQ Queries — Claude Skills 360 Blog
Blog / Backend / Claude Code for Sanity Studio: Headless CMS with GROQ Queries
Backend

Claude Code for Sanity Studio: Headless CMS with GROQ Queries

Published: February 28, 2027
Read time: 8 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free