Claude Code with Sanity: GROQ Queries, Schemas, Live Preview
Why Sanity without CLAUDE.md generates schemas that fight the Studio
Sanity's Studio is a structured content editor. The schema you define in TypeScript is the contract between developers, content editors, and the queries that power your front end. When that contract is loosely defined, editors encounter validation errors they cannot resolve, queries return shapes that do not match component props, and the Studio interface is harder to navigate than it needs to be.
Claude Code without a CLAUDE.md for Sanity makes four systematic mistakes.
The first is writing schemas as plain objects instead of using defineType and defineField. Plain objects work at runtime but lose the TypeScript inference that Sanity v3 generates via sanity typegen. Without it, every client.fetch() call returns any, and the type safety that makes the whole system trustworthy disappears.
The second is writing GraphQL-style queries. Claude's training data contains far more GraphQL than GROQ. When asked to fetch posts with their category names, Claude reaches for something that looks like a GraphQL fragment rather than GROQ's *[_type == "post"]{ title, "categories": categories[]->title } projection. The query fails immediately but the error message points to GROQ syntax, not GraphQL, so the cause is not always obvious.
The third is omitting apiVersion from the client config, or setting it to 'latest'. Sanity's Content Lake API is versioned by date string. Omitting the version means the client defaults to whatever version Sanity ships next, which can silently change query behaviour on schema upgrades. Setting a fixed date string pins the behaviour.
The fourth is setting useCdn: false in production. The CDN is Sanity's globally distributed read layer. Bypassing it in production routes every query to the primary dataset, which is slower and incurs additional API usage. The CDN is appropriate for all production reads. useCdn: false is correct only for draft previews that need unpublished content.
This guide covers the CLAUDE.md configuration that anchors Claude Code to Sanity v3's correct patterns: typed schemas with defineType and defineField, GROQ projection and dereference syntax, image URL builder configuration, Presentation tool live preview, and Studio deployment. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation. For Sanity paired with a Next.js front end, Claude Code with Next.js covers the data fetching conventions that compose with these patterns.
The Sanity CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For a Sanity project it needs to declare: Sanity version and config file location, the schema definition pattern, GROQ query conventions, client configuration, image handling, Studio deployment, and the hard rules that block the patterns Claude generates most often without guidance.
# Sanity rules
## Stack
- Sanity v3.x, TypeScript 5.x strict
- Next.js 14.x App Router (front end)
- sanity.config.ts at project root
- schemas/ directory for all document and object types
- sanity typegen for TypeScript types from schema
## Project structure
- sanity.config.ts , Studio configuration and plugin registration
- schemas/index.ts , exports schemaTypes array (all types concatenated)
- schemas/documents/ , document types (post.ts, author.ts, category.ts)
- schemas/objects/ , reusable object types (blockContent.ts, seo.ts, imageWithAlt.ts)
- lib/sanity.ts , createClient() configuration
- lib/queries.ts , all GROQ queries as named constants
- lib/image.ts , imageUrlBuilder instance
## Schema definition rules
- ALWAYS use defineType() for every schema type, never plain objects
- ALWAYS use defineField() for every field within a type, never plain objects
- ALWAYS add validation: (rule) => rule.required() on all required fields
- ALWAYS set title on every type and field (used in Studio UI)
- Document types: type: 'document'
- Object types: type: 'object'
- Reusable content: extract to schemas/objects/, import into documents
## Example document schema
import { defineType, defineField } from 'sanity';
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule) => rule.required().min(10).max(80),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'category' }] }],
}),
defineField({
name: 'mainImage',
title: 'Main image',
type: 'image',
options: { hotspot: true },
fields: [
defineField({ name: 'alt', title: 'Alt text', type: 'string' }),
],
}),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [{ type: 'block' }, { type: 'image', options: { hotspot: true } }],
}),
],
});
## GROQ query rules
- GROQ is NOT GraphQL, never use GraphQL fragment syntax
- All queries start with *[_type == "typename"] not { typename { ... } }
- Use projections to shape the response: *[_type == "post"]{ title, slug }
- Dereference with -> to follow references: categories[]->{ title, slug }
- Filter within a query: *[_type == "post" && !(_id in path("drafts.**"))]
- Order with | order(): *[_type == "post"] | order(publishedAt desc)
- Limit with [0..9] slice notation: *[_type == "post"] | order(publishedAt desc)[0..9]
- Get a single document by slug: *[_type == "post" && slug.current == $slug][0]
- Use $params for all variable values: client.fetch(query, { slug })
- NEVER interpolate user input into query strings
## GROQ example patterns
// Fetch post list with dereferenced categories
const postsQuery = `*[_type == "post" && !(_id in path("drafts.**"))] | order(publishedAt desc)[0..9]{
_id,
title,
"slug": slug.current,
publishedAt,
"categories": categories[]->{ title, "slug": slug.current },
"mainImage": mainImage{ asset->, alt }
}`;
// Fetch single post by slug
const postQuery = `*[_type == "post" && slug.current == $slug && !(_id in path("drafts.**"))][0]{
_id,
title,
"slug": slug.current,
publishedAt,
body,
"mainImage": mainImage{ asset->, alt },
"categories": categories[]->{ title, "slug": slug.current }
}`;
## Client configuration rules
- createClient() lives in lib/sanity.ts, imported everywhere, never recreated inline
- ALWAYS set apiVersion to a fixed date string: '2024-01-01'
- NEVER use apiVersion: 'latest'
- useCdn: true for all production read queries
- useCdn: false ONLY inside draft preview handlers (Next.js draftMode)
- perspective: 'published' for public queries
- perspective: 'previewDrafts' for preview handlers
## lib/sanity.ts
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: true,
});
export const previewClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
perspective: 'previewDrafts',
});
## Hard rules
- NEVER define schema types as plain objects without defineType()
- NEVER write GraphQL-style queries, always GROQ
- NEVER omit apiVersion from createClient()
- NEVER set useCdn: false in the production client
- NEVER interpolate variables into GROQ strings, always use $params
- ALWAYS add validation rules to required fields
- ALWAYS extract reusable field groups into schemas/objects/
- NEVER call createClient() more than once per environment (production/preview)
Three rules here prevent the majority of type and query errors Claude generates without them.
The defineType/defineField rule is the most impactful for long-term maintainability. sanity typegen reads your schema files and generates TypeScript types for every document and object type. Those types flow into your client.fetch() calls via generics: client.fetch<Post[]>(postsQuery). Without defineType and defineField, the type generator cannot produce accurate types, and the front end loses the guarantee that post.title is a string, not string | undefined | null. Claude generates plain objects when it does not know Sanity v3 prefers the function wrappers.
The GROQ rules matter because GROQ is a query language Claude sees far less training data for than SQL or GraphQL. The projection syntax, the -> dereference operator, and the | order() pipe syntax are all non-obvious from first principles. Without the examples in CLAUDE.md, Claude writes queries that look plausible but return different shapes than expected, or that fail silently by returning an empty array when the _type filter uses the wrong string.
The apiVersion rule prevents a specific class of production incident. Sanity introduces breaking changes behind new API version dates. If your client is pinned to '2024-01-01', a schema upgrade that changes a field type or query behaviour on a newer date will not affect you until you explicitly bump the version and test the change. Without the pin, a Sanity platform update can silently change your query results on the next cold deploy.
Project structure and sanity.config.ts
A Sanity v3 project has two entry points: sanity.config.ts for the Studio and lib/sanity.ts for the front end client. Claude conflates them. The CLAUDE.md structure section keeps them separate.
Add a project structure section to CLAUDE.md:
## sanity.config.ts (Studio configuration)
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import { schemaTypes } from './schemas';
export default defineConfig({
name: 'default',
title: 'My Project Studio',
projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
dataset: process.env.SANITY_STUDIO_DATASET!,
plugins: [
structureTool(),
visionTool(), // GROQ query playground, dev only
],
schema: {
types: schemaTypes,
},
});
## schemas/index.ts
import { post } from './documents/post';
import { author } from './documents/author';
import { category } from './documents/category';
import { blockContent } from './objects/blockContent';
import { seo } from './objects/seo';
export const schemaTypes = [post, author, category, blockContent, seo];
## Rules
- sanity.config.ts is Studio-only, never imported by the Next.js front end
- lib/sanity.ts is front-end-only, never imported by Studio config
- schemaTypes must be a flat array, no nested arrays
- visionTool() is dev convenience; it is fine to ship in production Studio
- Add plugins to the plugins array in sanity.config.ts, never inline in schema
The critical separation is that sanity.config.ts uses SANITY_STUDIO_PROJECT_ID (prefixed for the Studio's own build) while lib/sanity.ts uses NEXT_PUBLIC_SANITY_PROJECT_ID (prefixed for Next.js client-side access). Claude will often use the same env var name in both places, which causes the Studio to break when deployed separately from the front end. The CLAUDE.md makes the split explicit.
For Astro-based front ends that consume the same Sanity dataset, the fetch pattern is identical but the env var naming convention differs. Claude Code with Astro covers those conventions if you are building a content site rather than a Next.js app.
GROQ query patterns and the queries file
GROQ queries in a real project accumulate quickly: list queries, single-document queries, related content queries, preview queries. Without a single place to organise them, they spread across page components, API routes, and utility files. Claude will generate queries inline wherever they are needed first. The lib/queries.ts convention keeps them findable.
Add a queries section to CLAUDE.md:
## lib/queries.ts conventions
- Export every GROQ query as a named constant
- Group by document type: POSTS_QUERY, POST_QUERY, AUTHOR_QUERY
- Params variables in the query string must match the params object passed to client.fetch()
- Write queries for the published perspective first, add preview variants only when needed
## Example queries file
export const POSTS_LIST_QUERY = `*[_type == "post" && !(_id in path("drafts.**"))] | order(publishedAt desc)[0..9]{
_id,
title,
"slug": slug.current,
publishedAt,
"categories": categories[]->{ title },
"mainImage": mainImage{ asset->{ url }, alt }
}`;
export const POST_QUERY = `*[_type == "post" && slug.current == $slug && !(_id in path("drafts.**"))][0]{
_id,
title,
"slug": slug.current,
publishedAt,
body,
"author": author->{ name, "image": image.asset->{ url } },
"categories": categories[]->{ title, "slug": slug.current },
"mainImage": mainImage{ asset->{ url }, alt },
seo
}`;
export const ALL_POST_SLUGS_QUERY = `*[_type == "post" && defined(slug.current)]{
"slug": slug.current
}`;
## Usage in Next.js App Router
import { client } from '@/lib/sanity';
import { POSTS_LIST_QUERY, POST_QUERY } from '@/lib/queries';
// In a page component (server component, no useEffect)
const posts = await client.fetch(POSTS_LIST_QUERY);
const post = await client.fetch(POST_QUERY, { slug: params.slug });
## Rules
- NEVER fetch in a useEffect for data that is available at render time
- App Router server components fetch directly with await, no hooks
- generateStaticParams uses ALL_POST_SLUGS_QUERY to enumerate slugs at build time
- client.fetch() is typed with a generic: client.fetch<Post[]>(POSTS_LIST_QUERY)
The !(_id in path("drafts.**")) filter is the one Claude consistently omits. Without it, queries return both published documents and their draft counterparts, doubling the result set and exposing unpublished content. It belongs in every list and single-document query that targets the published perspective. The CLAUDE.md makes it a required part of the template rather than something to remember per-query.
Image asset handling with @sanity/image-url
Sanity stores images as asset references, not direct URLs. The asset reference contains a document ID that resolves to a CDN URL. Building that URL requires the @sanity/image-url package and a builder instance configured with your project credentials.
Add an image section to CLAUDE.md:
## lib/image.ts (image URL builder)
import imageUrlBuilder from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
import { client } from './sanity';
const builder = imageUrlBuilder(client);
export function urlFor(source: SanityImageSource) {
return builder.image(source);
}
// Usage examples
// urlFor(post.mainImage).width(800).url()
// urlFor(post.mainImage).width(400).height(300).fit('crop').url()
// urlFor(post.mainImage).width(1200).format('webp').quality(80).url()
## Rules
- ALWAYS use urlFor() to build image URLs, never construct the CDN URL manually
- ALWAYS call .url() at the end of the builder chain to get a string
- Set explicit width on every image: .width(800).url()
- Use .format('webp').quality(80) for production images
- Use .fit('crop') when you need a specific aspect ratio
- The mainImage field in the schema must have options: { hotspot: true } for cropping to work
- Alt text lives on the image field itself: mainImage{ asset->, alt }
## Component pattern
import { urlFor } from '@/lib/image';
interface PostHeroProps {
image: { asset: SanityImageSource; alt: string };
}
export function PostHero({ image }: PostHeroProps) {
return (
<img
src={urlFor(image).width(1200).format('webp').quality(80).url()}
alt={image.alt ?? ''}
width={1200}
height={630}
/>
);
}
Claude's most common image mistake is omitting .url() at the end of the builder chain. urlFor(image).width(800) returns a builder object, not a string. Passing a builder object to src silently renders [object Object] in the browser. The .url() call converts it. The CLAUDE.md shows the complete pattern including the final call, which Claude will replicate.
The second common mistake is building the CDN URL by concatenating the asset._ref string manually. The _ref format (image-abc123-800x600-jpg) encodes the project, dataset, filename, and extension but requires the builder to resolve it correctly. Manual string manipulation breaks on edge cases: SVG assets, files with unusual names, and assets from other datasets. urlFor() handles all of them correctly.
Live preview with the Presentation tool
Sanity Studio ships a Presentation tool that renders your front-end route alongside the Studio editor. When a content editor changes a field, the preview updates without a full page reload. Setting this up requires Next.js draftMode, a previewClient that reads from the previewDrafts perspective, and a route handler that enables draft mode via a secret token.
Add a live preview section to CLAUDE.md:
## Live preview setup (Next.js App Router + Presentation tool)
## Required env vars
SANITY_API_READ_TOKEN= # Viewer token from sanity.io/manage
SANITY_PREVIEW_SECRET= # Random string, shared between Studio and Next.js
## app/api/draft/route.ts (enable draft mode)
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(`/posts/${slug}`);
}
## Server component with draft mode detection
import { draftMode } from 'next/headers';
import { client, previewClient } from '@/lib/sanity';
import { POST_QUERY } from '@/lib/queries';
export default async function PostPage({ params }: { params: { slug: string } }) {
const { isEnabled: isDraftMode } = draftMode();
const sanityClient = isDraftMode ? previewClient : client;
const post = await sanityClient.fetch(POST_QUERY, { slug: params.slug });
return <article>{post.title}</article>;
}
## sanity.config.ts: add Presentation plugin
import { presentationTool } from 'sanity/presentation';
export default defineConfig({
plugins: [
structureTool(),
visionTool(),
presentationTool({
previewUrl: {
origin: process.env.SANITY_STUDIO_PREVIEW_URL ?? 'http://localhost:3000',
previewMode: {
enable: '/api/draft',
},
},
}),
],
});
## Rules
- previewClient ALWAYS uses useCdn: false and perspective: 'previewDrafts'
- production client ALWAYS uses useCdn: true and no explicit perspective (defaults to 'published')
- The draft route handler must validate the secret before enabling draftMode()
- NEVER expose SANITY_API_READ_TOKEN to the browser (no NEXT_PUBLIC_ prefix)
- NEVER use the write token for preview; use a Viewer-role read token instead
The token role matters. Sanity has three token roles: Viewer, Editor, and Administrator. The preview token should be a Viewer token. It needs to read unpublished content from the previewDrafts perspective, but it should not have write access. Claude will sometimes use the same token for both preview and Studio mutations, which unnecessarily expands the blast radius of a leaked token. The explicit rule keeps the least-privilege pattern.
Deploying the Studio with sanity deploy
Sanity Studio can be deployed to Sanity's hosting at {name}.sanity.studio. The sanity deploy command builds the Studio and uploads it. It does not require a custom server and does not share infrastructure with the front-end deployment.
Add a deployment section to CLAUDE.md:
## Studio deployment
## Commands
sanity deploy # Deploy Studio to {name}.sanity.studio
sanity deploy --no-build # Re-deploy from existing .sanity/dist (fast re-deploy)
## Required before first deploy
- sanity.config.ts must have projectId, dataset, and name set
- name in defineConfig() becomes the subdomain: name: 'my-project' -> my-project.sanity.studio
- CORS origins must include the Studio URL: add in sanity.io/manage -> API -> CORS origins
## CORS origins to add in sanity.io/manage
- https://my-project.sanity.studio (allow credentials: true)
- http://localhost:3333 (allow credentials: false, dev only)
- https://www.yourproductiondomain.com (allow credentials: true)
## .env.production (Studio environment)
SANITY_STUDIO_PROJECT_ID=your-project-id
SANITY_STUDIO_DATASET=production
SANITY_STUDIO_PREVIEW_URL=https://www.yourproductiondomain.com
## Rules
- sanity deploy is for Studio hosting only, it does not deploy the front end
- Front end deploys independently (Vercel, Netlify, etc.)
- NEVER commit .env.production with real values, use CI secrets
- Add CORS origins before deploying Studio or Studio will fail to authenticate
- Run sanity deploy from the project root where sanity.config.ts lives
The CORS origin step is the one that causes the most confusion after deployment. When the Studio is hosted at my-project.sanity.studio and the CORS list in sanity.io/manage does not include that origin, every API call from the Studio fails with a 403. Claude generates the deploy command correctly but does not mention the CORS configuration because it is in the Sanity dashboard rather than in code. Including it in CLAUDE.md makes it part of the deployment checklist.
For MDX-based content sites where Sanity is the content source rather than the content format, Claude Code with MDX covers how to compose Sanity data with MDX rendering. For Supabase as the relational data layer alongside Sanity for content, Claude Code with Supabase covers the boundary between structured data and content.
Common gotchas Claude hits without guidance
Six patterns cause the most failures when Claude Code generates Sanity integrations without a CLAUDE.md.
Plain object schemas. Claude writes { name: 'post', type: 'document', fields: [...] } instead of defineType({ name: 'post', type: 'document', fields: [...] }). Both render the same Studio UI but the plain object form disables type inference from sanity typegen. The fix is mechanical: wrap every schema export in defineType() and every field in defineField(). Include this in CLAUDE.md as a hard block with an example, not just a preference.
GraphQL projection syntax. When asked to write a query for posts with their author name, Claude produces something resembling { posts { title author { name } } } rather than *[_type == "post"]{ title, author->{ name } }. The curly-brace projection in GROQ looks similar to GraphQL selection sets, but GROQ always starts with the source expression (*[filter]), uses -> for dereferences, and does not use fragment syntax. The queries section of the CLAUDE.md with complete examples is the most reliable fix.
Missing !(_id in path("drafts.**")) filter. Without this filter, *[_type == "post"] returns both published documents and their draft counterparts. If a post has been edited but not published, the list query returns both the published version and the draft, and the slug may appear twice in generateStaticParams, causing a build error. The filter belongs in every list query targeting the published perspective.
Hardcoded project credentials. Claude sometimes writes createClient({ projectId: 'abc123', dataset: 'production', ... }) with literal values instead of env vars. This is a direct secret exposure. The CLAUDE.md client config section shows the env var pattern, which Claude will replicate.
client.fetch() without generic types. client.fetch(POSTS_LIST_QUERY) returns any. client.fetch<Post[]>(POSTS_LIST_QUERY) returns the typed array. The generic is optional in JavaScript but mandatory for TypeScript type safety. Claude omits it without the explicit rule. Include the typed pattern in the queries section so Claude generates typed fetch calls.
Forgetting options: { hotspot: true } on image fields. Without hotspot: true, the image field in the Studio does not show the hotspot selector, and the URL builder's cropping methods (.fit('crop'), .focalPoint()) will not produce the intended result. Every image field that will be cropped to different aspect ratios needs this option. The schema example in CLAUDE.md should include it.
Building a content layer that Claude understands
The Sanity CLAUDE.md in this guide produces an integration where schemas are typed because they use defineType and defineField, queries return predictable shapes because they use correct GROQ projection and dereference syntax, images resolve to correct URLs because they go through the @sanity/image-url builder, live preview works because the draft client uses the correct perspective and the route handler validates the secret, the Studio is accessible at a predictable URL because CORS origins are configured before deploy, and production performance is correct because useCdn: true routes reads through the CDN.
The underlying principle is the same as any framework integration with Claude Code. A Sanity project without a CLAUDE.md produces schemas that lose type inference, queries that return wrong shapes, and clients that bypass the CDN in production, because Claude applies general TypeScript patterns where Sanity-specific ones are required. A project with the configuration above has one remaining source of errors: the content itself. Schema mismatches, query bugs, and client configuration problems are eliminated before they reach the editor or the browser.
For the mechanics of how CLAUDE.md is read at session start and how to version it alongside your schema files, see CLAUDE.md explained. Claudify includes a Sanity-specific CLAUDE.md template, pre-configured for the defineType schema pattern, GROQ query conventions, image URL builder setup, Presentation tool live preview, and Studio deployment shown in this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify