Claude Skill

api-cms-sanity

Structured content platform — GROQ queries, schema definitions, @sanity/client, Portable Text, image handling, real-time listeners, mutations, TypeGen

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-cms-sanity_skills_api-cms-sanity-3a51ef5.zip · 21 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-cms-sanity/skills/api-cms-sanity
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Sanity Patterns

Quick Guide: Use Sanity for structured content management with GROQ queries, typed schemas via defineType/defineField, and @sanity/client for data fetching. Always set apiVersion to a dated string, use useCdn: true for public reads, handle draft documents explicitly, use @sanity/image-url for image transformations, and render rich text with @portabletext/react. Generate TypeScript types with sanity typegen generate.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST always set apiVersion on createClient to a dated string like '2025-02-19' — omitting it uses a legacy API that may break)

(You MUST use useCdn: true for public read queries and useCdn: false when using a token or needing fresh data)

(You MUST use parameterized GROQ queries ($param) for any dynamic values — never interpolate user input into GROQ strings)

(You MUST handle drafts explicitly — draft documents have _id prefixed with drafts. and are not returned by default with perspective: 'published')

(You MUST use defineQuery() from groq and assign queries to named variables for TypeGen type generation)

</critical_requirements>


Auto-detection: Sanity, sanity, @sanity/client, createClient, GROQ, groq, defineType, defineField, defineArrayMember, @sanity/image-url, urlFor, @portabletext/react, PortableText, portable text, block content, sanity.config, sanity.cli, typegen, sanity studio, content lake

When to use:

  • Setting up @sanity/client with createClient for data fetching
  • Writing GROQ queries (filters, projections, joins, ordering, slicing)
  • Defining content schemas with defineType, defineField, defineArrayMember
  • Rendering Portable Text (block content) with @portabletext/react
  • Generating image URLs with @sanity/image-url (responsive images, crops, hotspots)
  • Performing mutations (create, patch, delete, transactions)
  • Setting up real-time listeners with client.listen()
  • Generating TypeScript types with Sanity TypeGen

Key patterns covered:

  • Client setup with createClient and apiVersion configuration
  • GROQ query language: filters, projections, ordering, slicing, joins, references
  • Schema definitions: document types, object types, arrays, references, images
  • Portable Text rendering with custom components
  • Image URL builder with responsive images and transformations
  • Mutations: create, createOrReplace, patch, delete, transactions
  • Real-time listeners via client.listen()
  • TypeGen for type-safe GROQ queries with defineQuery()

When NOT to use:

  • GraphQL-only APIs (Sanity supports GROQ primarily; use GraphQL skill if needed)
  • Direct database access (Sanity is a hosted content lake, not a database)
  • Non-Sanity CMS platforms (use the dedicated skill for your CMS)

Detailed Resources:

  • For decision frameworks and quick-reference tables, see reference.md

Client & GROQ:

Schemas:

  • examples/schemas.md — defineType, defineField, document types, object types, references, images

Rich Content:

Mutations & Real-time:




<decision_framework>

Decision Framework

useCdn: true vs false

Is the data public and non-personalized?
├─ YES → useCdn: true (edge-cached, fast)
└─ NO →
    ├─ Using a token for authenticated reads? → useCdn: false
    ├─ Need real-time fresh data (preview)? → useCdn: false
    └─ Performing mutations? → useCdn: false

Draft Handling

Do you need draft documents?
├─ YES → Use perspective: 'previewDrafts' (requires token)
├─ NO → Use perspective: 'published' (default since API v2025-02-19)
└─ Mixed (preview mode toggle)?
    └─ Create two clients: one public (useCdn: true), one preview (token + useCdn: false)

GROQ Query Return Shape

How many documents do you expect?
├─ One (by ID, slug, singleton) → Add [0] at end (returns object or null)
├─ Many (list, feed) → No slice suffix (returns array)
└─ Paginated → Add [start...end] slice

Image Handling

How should images be delivered?
├─ Fixed size (thumbnails, avatars) → urlFor(img).width(W).height(H).url()
├─ Responsive (article images) → srcSet with multiple widths
├─ Format optimization → .auto('format') for WebP/AVIF
└─ Cropped to aspect ratio → .width(W).height(H).fit('crop')

Mutation Method Selection

What operation do you need?
├─ Create new document → client.create()
├─ Create or fully replace → client.createOrReplace() (for singletons)
├─ Create only if missing → client.createIfNotExists()
├─ Update specific fields → client.patch(id).set({...}).commit()
├─ Remove fields → client.patch(id).unset([...]).commit()
├─ Multiple related changes → client.transaction()...commit()
└─ Delete document → client.delete(id)

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Missing apiVersion on client — Omitting apiVersion uses legacy API behavior that may change without notice. Always pin to a date string.
  • String interpolation in GROQ queries — Interpolating user input into GROQ strings enables GROQ injection. Always use $param parameters.
  • Using useCdn: true with a token — CDN-cached responses ignore authentication tokens. Authenticated queries must use useCdn: false.
  • Accessing draft documents without a token — Drafts (drafts.* documents) require an API token and perspective: 'previewDrafts'. Without a token, drafts are invisible.

Medium Priority Issues:

  • Fetching all fields with {...} when only a few are needed — Over-fetching wastes bandwidth and CDN cache efficiency. Project only the fields you need.
  • Missing .commit() on patches — client.patch(id).set({...}) without .commit() does nothing — the mutation is never sent.
  • Not using defineQuery() for GROQ queries — TypeGen cannot generate types for queries that aren't wrapped in defineQuery() or assigned to named variables.
  • Hardcoded project ID or dataset — Use environment variables; hardcoded values prevent environment switching and leak project details.
  • Using deprecated @sanity/block-content-to-react — Replaced by @portabletext/react. The old package is unmaintained.

Common Mistakes:

  • Forgetting _key on array items in mutations — Every item in a Sanity array must have a unique _key field. Mutations without _key will fail.
  • Using _id with client.create() — create() generates a random _id. If you specify _id and the document exists, it errors. Use createOrReplace() or createIfNotExists() for idempotent operations.
  • Not handling the case where [0] returns null — A GROQ query ending in [0] returns null if no documents match, not an empty object.
  • Expecting client.listen() to work with projections — The listener only uses the filter portion of a GROQ query. Projections, ordering, and slicing are ignored.

Gotchas & Edge Cases:

  • API version 2025-02-19 changed default perspective — Before this version, the default perspective was raw (includes drafts). After, it defaults to published. Existing code may break if you update apiVersion without accounting for this.
  • CDN cache is eventual — After a mutation, CDN-cached queries (useCdn: true) may return stale data for a few seconds. Use useCdn: false or add a small delay for consistency-critical reads after writes.
  • Slug fields store value in .current — Query slug.current, not slug directly. *[slug == "my-slug"] will never match.
  • Image fields require the full object for crop/hotspot — Passing only asset._ref to urlFor() works for basic URLs but loses crop and hotspot metadata. Pass the entire image field object.
  • Portable Text arrays need _key on every block — When creating Portable Text content programmatically, every block and inline object needs a unique _key.
  • client.listen() reconnects automatically — But there's no built-in guarantee against missed events during reconnection. For critical use cases, combine with periodic re-fetching.
  • TypeGen requires schema.json extraction first — Run npx sanity schema extract before npx sanity typegen generate. The extract step reads your Studio schemas and outputs a JSON representation.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST always set apiVersion on createClient to a dated string like '2025-02-19' — omitting it uses a legacy API that may break)

(You MUST use useCdn: true for public read queries and useCdn: false when using a token or needing fresh data)

(You MUST use parameterized GROQ queries ($param) for any dynamic values — never interpolate user input into GROQ strings)

(You MUST handle drafts explicitly — draft documents have _id prefixed with drafts. and are not returned by default with perspective: 'published')

(You MUST use defineQuery() from groq and assign queries to named variables for TypeGen type generation)

Failure to follow these rules will cause unpredictable API behavior, GROQ injection vulnerabilities, and untyped query results.

</critical_reminders>

Files (skills)
  • examples
    • core.md 10.2 KB
      # Sanity Core Examples
      
      > Client setup, GROQ queries, error handling, and TypeGen patterns. See [SKILL.md](../SKILL.md) for core concepts.
      
      **Schemas:** See [schemas.md](schemas.md). **Rich content:** See [rich-content.md](rich-content.md). **Mutations:** See [mutations.md](mutations.md).
      
      ---
      
      ## Pattern 1: Client Setup — Public Reads
      
      ### Good Example — Typed Client with API Version
      
      ```typescript
      // lib/sanity-client.ts
      import { createClient } from "@sanity/client";
      
      const PROJECT_ID = process.env.SANITY_PROJECT_ID!;
      const DATASET = process.env.SANITY_DATASET!;
      const API_VERSION = "2025-02-19";
      
      export const client = createClient({
        projectId: PROJECT_ID,
        dataset: DATASET,
        apiVersion: API_VERSION,
        useCdn: true, // Edge-cached for public reads
      });
      ```
      
      **Why good:** `apiVersion` pins API behavior to a known date, `useCdn: true` serves cached responses from CDN edge, named constants for config values, named export
      
      ### Bad Example — Missing API Version
      
      ```typescript
      import { createClient } from "@sanity/client";
      
      // BAD: No apiVersion, hardcoded values
      const client = createClient({
        projectId: "abc123",
        dataset: "production",
      });
      ```
      
      **Why bad:** Missing `apiVersion` uses legacy API behavior that may change without warning, hardcoded project ID prevents environment switching, no `useCdn` defaults to `false` (always hitting origin)
      
      ---
      
      ## Pattern 2: Client Setup — Authenticated / Preview
      
      ### Good Example — Token-Based Client for Preview Mode
      
      ```typescript
      // lib/sanity-preview-client.ts
      import { createClient } from "@sanity/client";
      
      const PROJECT_ID = process.env.SANITY_PROJECT_ID!;
      const DATASET = process.env.SANITY_DATASET!;
      const API_VERSION = "2025-02-19";
      const API_TOKEN = process.env.SANITY_API_TOKEN!;
      
      // Preview client: sees drafts, no CDN cache
      export const previewClient = createClient({
        projectId: PROJECT_ID,
        dataset: DATASET,
        apiVersion: API_VERSION,
        useCdn: false, // Must be false when using token
        token: API_TOKEN,
        perspective: "previewDrafts", // Include draft documents
      });
      ```
      
      **Why good:** `useCdn: false` required with tokens (CDN ignores auth), `perspective: 'previewDrafts'` returns draft documents overlaid on published, server-only token kept in environment variable
      
      ### Good Example — Dual Client Pattern
      
      ```typescript
      // lib/sanity-clients.ts
      import { createClient } from "@sanity/client";
      
      const PROJECT_ID = process.env.SANITY_PROJECT_ID!;
      const DATASET = process.env.SANITY_DATASET!;
      const API_VERSION = "2025-02-19";
      
      // Public reads — CDN cached
      export const client = createClient({
        projectId: PROJECT_ID,
        dataset: DATASET,
        apiVersion: API_VERSION,
        useCdn: true,
      });
      
      // Preview reads — fresh, includes drafts (server-only)
      export const previewClient = createClient({
        projectId: PROJECT_ID,
        dataset: DATASET,
        apiVersion: API_VERSION,
        useCdn: false,
        token: process.env.SANITY_API_TOKEN!,
        perspective: "previewDrafts",
      });
      
      // Choose client based on preview mode
      export function getClient(preview = false) {
        return preview ? previewClient : client;
      }
      ```
      
      **Why good:** Separate clients for public and preview, factory function selects the right client, preview token never sent to CDN, shared config for consistency
      
      ---
      
      ## Pattern 3: GROQ Queries — Filters and Projections
      
      ### Good Example — Parameterized Query with defineQuery
      
      ```typescript
      import { defineQuery } from "groq";
      
      // List query: multiple documents
      const POSTS_QUERY = defineQuery(`
        *[_type == "post" && published == true]{
          _id,
          title,
          "slug": slug.current,
          "authorName": author->name,
          "excerpt": pt::text(body)[0...200],
          publishedAt
        } | order(publishedAt desc) [0...10]
      `);
      
      // Detail query: single document by slug
      const POST_BY_SLUG_QUERY = defineQuery(`
        *[_type == "post" && slug.current == $slug][0]{
          _id,
          title,
          body,
          publishedAt,
          "author": author->{
            name,
            "imageUrl": image.asset->url
          },
          "categories": categories[]->{ _id, title },
          "relatedPosts": *[
            _type == "post"
            && _id != ^._id
            && count(categories[@._ref in ^.^.categories[]._ref]) > 0
          ][0...3]{ _id, title, "slug": slug.current }
        }
      `);
      
      // Fetch with parameters
      const post = await client.fetch(POST_BY_SLUG_QUERY, { slug: "my-post" });
      ```
      
      **Why good:** `defineQuery()` enables TypeGen type inference, `$slug` parameter prevents injection, `[0]` returns single object (not array), `->` dereferences references, `pt::text()` extracts plain text from Portable Text, `^` references parent scope in subqueries
      
      ### Bad Example — String Interpolation
      
      ```typescript
      // BAD: GROQ injection vulnerability
      async function getPostBySlug(slug: string) {
        const query = `*[_type == "post" && slug.current == "${slug}"][0]`;
        return client.fetch(query);
      }
      ```
      
      **Why bad:** User-controlled `slug` value interpolated directly into GROQ string, enables GROQ injection attacks (e.g., `" || true] | order(_createdAt desc)[0]{"token": identity()}//`), always use `$param` parameters instead
      
      ---
      
      ## Pattern 4: GROQ Queries — Advanced Patterns
      
      ### Good Example — Combined Query (Multiple Results in One Fetch)
      
      ```typescript
      const PAGE_QUERY = defineQuery(`{
        "settings": *[_type == "siteSettings"][0]{
          title,
          description,
          "logoUrl": logo.asset->url
        },
        "featuredPosts": *[_type == "post" && featured == true] | order(publishedAt desc) [0...3]{
          _id,
          title,
          "slug": slug.current,
          publishedAt
        },
        "categories": *[_type == "category"] | order(title asc){
          _id,
          title,
          "postCount": count(*[_type == "post" && references(^._id)])
        }
      }`);
      
      // Single fetch returns all three datasets
      const pageData = await client.fetch(PAGE_QUERY);
      // pageData.settings, pageData.featuredPosts, pageData.categories
      ```
      
      **Why good:** Single network request for multiple related datasets, each sub-query independently filtered and projected, `count()` with subquery for computed fields, reduces page load time
      
      ### Good Example — Conditional Projections
      
      ```typescript
      const CONTENT_BLOCKS_QUERY = defineQuery(`
        *[_type == "page" && slug.current == $slug][0]{
          title,
          "content": content[]{
            _type == "hero" => {
              _type,
              heading,
              "backgroundUrl": background.asset->url
            },
            _type == "textBlock" => {
              _type,
              body
            },
            _type == "gallery" => {
              _type,
              "images": images[]{
                "url": asset->url,
                alt,
                caption
              }
            }
          }
        }
      `);
      ```
      
      **Why good:** Conditional projections return different fields based on `_type`, polymorphic content blocks handled in a single query, each block type gets only its relevant fields
      
      ---
      
      ## Pattern 5: Error Handling
      
      ### Good Example — Consistent Fetch Error Handling
      
      ```typescript
      async function fetchPosts() {
        try {
          const posts = await client.fetch(POSTS_QUERY);
          return posts;
        } catch (error) {
          // Sanity client throws on network errors and GROQ syntax errors
          const message = error instanceof Error ? error.message : "Unknown error";
          throw new Error(`Failed to fetch posts: ${message}`);
        }
      }
      
      // For single documents that might not exist
      async function fetchPostBySlug(slug: string) {
        const post = await client.fetch(POST_BY_SLUG_QUERY, { slug });
      
        if (!post) {
          // [0] returns null when no documents match
          throw new Error(`Post not found: ${slug}`);
        }
      
        return post;
      }
      ```
      
      **Why good:** `client.fetch` throws on network/GROQ errors (not a `{ data, error }` tuple), null check for `[0]` queries that may return no results, descriptive error messages with context
      
      ### Bad Example — No Null Handling
      
      ```typescript
      // BAD: Assumes document always exists
      async function getPost(slug: string) {
        const post = await client.fetch(POST_BY_SLUG_QUERY, { slug });
        return post.title; // TypeError if post is null!
      }
      ```
      
      **Why bad:** GROQ `[0]` returns `null` if no documents match, accessing `.title` on `null` throws at runtime, must check for `null` before accessing properties
      
      ---
      
      ## Pattern 6: TypeGen Integration
      
      ### Good Example — Full TypeGen Workflow
      
      ```typescript
      // sanity.cli.ts
      import { defineCliConfig } from "sanity/cli";
      
      // NOTE: default export required by Sanity CLI tooling
      export default defineCliConfig({
        api: {
          projectId: "your-project-id",
          dataset: "production",
        },
        typegen: {
          enabled: true,
          path: "./src/**/*.{ts,tsx}",
          schema: "schema.json",
          generates: "./sanity.types.ts",
          overloadClientMethods: true,
        },
      });
      ```
      
      ```typescript
      // queries/post-queries.ts
      import { defineQuery } from "groq";
      
      // TypeGen finds these and generates result types
      export const allPostsQuery = defineQuery(`
        *[_type == "post"]{
          _id,
          title,
          "slug": slug.current,
          publishedAt
        } | order(publishedAt desc)
      `);
      
      export const postBySlugQuery = defineQuery(`
        *[_type == "post" && slug.current == $slug][0]{
          _id,
          title,
          body,
          "author": author->{name, "imageUrl": image.asset->url}
        }
      `);
      ```
      
      ```typescript
      // lib/fetchers.ts
      import type {
        AllPostsQueryResult,
        PostBySlugQueryResult,
      } from "../sanity.types";
      import { allPostsQuery, postBySlugQuery } from "../queries/post-queries";
      import { client } from "./sanity-client";
      
      // With overloadClientMethods: true, return types are inferred
      export async function getAllPosts(): Promise<AllPostsQueryResult> {
        return client.fetch(allPostsQuery);
      }
      
      export async function getPostBySlug(
        slug: string,
      ): Promise<PostBySlugQueryResult> {
        return client.fetch(postBySlugQuery, { slug });
      }
      ```
      
      **Why good:** `overloadClientMethods: true` makes `client.fetch` aware of query types, generated types match the exact shape of GROQ projections, queries in dedicated files for organization, explicit return types for clarity
      
      ### Bad Example — Inline Queries Without defineQuery
      
      ```typescript
      // BAD: TypeGen cannot type inline queries
      const posts = await client.fetch(`*[_type == "post"]{ title }`);
      // posts is typed as 'any' — no type safety
      ```
      
      **Why bad:** TypeGen only generates types for queries assigned to named variables using `defineQuery()` or the `groq` template literal, inline strings produce `any` return type, no type safety on the result
      
      ---
      
      _For schema definitions, see [schemas.md](schemas.md). For Portable Text and images, see [rich-content.md](rich-content.md). For mutations and real-time, see [mutations.md](mutations.md)._
      
    • mutations.md 8.1 KB
      # Sanity Mutations & Real-Time Examples
      
      > Create, patch, delete, transactions, and real-time listeners. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup.
      
      **Prerequisites**: Understand client setup from core examples first. Mutations require a client configured with `useCdn: false` and a write-capable API token.
      
      ---
      
      ## Pattern 1: Creating Documents
      
      ### Good Example — Create with Type Safety
      
      ```typescript
      import { client } from "../lib/sanity-client";
      
      // create() auto-generates _id if not provided
      const newPost = await client.create({
        _type: "post",
        title: "New Blog Post",
        slug: { _type: "slug", current: "new-blog-post" },
        published: false,
        publishedAt: new Date().toISOString(),
      });
      // newPost._id is the auto-generated document ID
      
      // createIfNotExists() — only creates if _id doesn't exist
      await client.createIfNotExists({
        _id: "singleton-site-settings",
        _type: "siteSettings",
        title: "My Site",
      });
      
      // createOrReplace() — overwrites entirely if document exists
      await client.createOrReplace({
        _id: "singleton-site-settings",
        _type: "siteSettings",
        title: "Updated Site Title",
        description: "New description",
      });
      ```
      
      **Why good:** `create()` for new documents with auto-generated IDs, `createIfNotExists()` for idempotent initialization, `createOrReplace()` for singletons that should always reflect the latest state, slug includes `_type: "slug"` as required by the schema
      
      ### Bad Example — Create with Existing ID
      
      ```typescript
      // BAD: create() with an _id that already exists will error
      await client.create({
        _id: "existing-doc-id",
        _type: "post",
        title: "This will fail",
      });
      ```
      
      **Why bad:** `create()` fails if a document with the given `_id` already exists — use `createOrReplace()` or `createIfNotExists()` for idempotent operations
      
      ---
      
      ## Pattern 2: Patching Documents
      
      ### Good Example — Various Patch Operations
      
      ```typescript
      // Set fields
      await client
        .patch("post-123")
        .set({
          title: "Updated Title",
          published: true,
          publishedAt: new Date().toISOString(),
        })
        .commit();
      
      // Unset (remove) fields
      await client.patch("post-123").unset(["temporaryFlag", "draftNotes"]).commit();
      
      // Increment/decrement numeric fields
      await client.patch("post-123").inc({ viewCount: 1 }).commit();
      await client.patch("post-123").dec({ remainingSlots: 1 }).commit();
      
      // Insert into an array at a specific position
      await client
        .patch("post-123")
        .insert("after", "tags[-1]", [
          { _key: crypto.randomUUID(), label: "typescript" },
        ])
        .commit();
      
      // Append to end of array
      await client
        .patch("post-123")
        .append("tags", [{ _key: crypto.randomUUID(), label: "new-tag" }])
        .commit();
      
      // Chain multiple patch operations
      await client
        .patch("post-123")
        .set({ title: "New Title" })
        .inc({ revisionCount: 1 })
        .commit();
      ```
      
      **Why good:** `.commit()` always called to send the mutation, `_key` included on array items (required by Sanity), `crypto.randomUUID()` generates unique keys, multiple operations chained in a single patch
      
      ### Bad Example — Missing commit()
      
      ```typescript
      // BAD: Patch without .commit() does nothing!
      client.patch("post-123").set({ title: "This is never sent" });
      // No network request made — mutation is discarded
      ```
      
      **Why bad:** Without `.commit()`, the patch is constructed but never sent to the API — this is a silent no-op
      
      ---
      
      ## Pattern 3: Deleting Documents
      
      ### Good Example — Delete by ID
      
      ```typescript
      // Delete a single document
      await client.delete("post-123");
      
      // Delete with specific options
      await client.delete("post-123", {
        visibility: "async", // Return immediately, sync in background
      });
      ```
      
      **Why good:** Simple single-document deletion by ID, `visibility` option controls when the request returns
      
      ---
      
      ## Pattern 4: Transactions (Atomic Multi-Mutation)
      
      ### Good Example — Grouped Mutations
      
      ```typescript
      // All mutations in a transaction succeed or fail together
      await client
        .transaction()
        .create({
          _type: "activityLog",
          action: "archived",
          documentId: "post-123",
          timestamp: new Date().toISOString(),
        })
        .patch("post-123", (p) =>
          p.set({ archived: true, archivedAt: new Date().toISOString() }),
        )
        .commit();
      
      // Transaction with multiple deletes
      const idsToDelete = ["post-1", "post-2", "post-3"];
      const tx = client.transaction();
      for (const id of idsToDelete) {
        tx.delete(id);
      }
      await tx.commit();
      ```
      
      **Why good:** Transaction ensures atomicity (both log creation and archival happen or neither does), callback style for patch within transaction, batch deletes in a single atomic operation
      
      ---
      
      ## Pattern 5: Mutation Visibility Options
      
      ### Good Example — Controlling Write Consistency
      
      ```typescript
      // sync (default): Waits for mutation to be committed AND indexed
      // Queries immediately see the change
      await client.create(doc, { visibility: "sync" });
      
      // async: Returns after commit, indexes in background
      // Faster response, but queries may not reflect the change immediately
      await client.create(doc, { visibility: "async" });
      
      // Dry run: Validates without applying
      const result = await client.create(doc, { dryRun: true });
      // Useful for validating mutations before executing them
      ```
      
      **Why good:** `sync` for consistency-critical operations (user sees their own change), `async` for bulk operations where speed matters, `dryRun` for validation without side effects
      
      ---
      
      ## Pattern 6: Real-Time Listeners
      
      ### Good Example — Subscribe to Document Changes
      
      ```typescript
      // Listen for changes to published posts
      const LISTENER_QUERY = `*[_type == "post" && published == true]`;
      
      const subscription = client.listen(LISTENER_QUERY).subscribe({
        next: (update) => {
          if (update.type === "mutation") {
            const { documentId, transition, result } = update;
            // transition: 'update' | 'appear' | 'disappear'
      
            switch (transition) {
              case "appear":
                // Document now matches the filter (new or newly matching)
                console.log(`New post appeared: ${documentId}`);
                break;
              case "update":
                // Existing matching document was modified
                console.log(`Post updated: ${documentId}`);
                break;
              case "disappear":
                // Document no longer matches the filter (deleted or no longer matching)
                console.log(`Post disappeared: ${documentId}`);
                break;
            }
          }
        },
        error: (err) => {
          console.error("Listener error:", err.message);
        },
      });
      
      // Cleanup: always unsubscribe when done
      subscription.unsubscribe();
      ```
      
      **Why good:** Observable-based subscription with error handling, `transition` field indicates what happened, cleanup via `unsubscribe()` prevents connection leaks, GROQ filter scopes to relevant documents
      
      ### Good Example — Listener with Options
      
      ```typescript
      // Listen with include options
      const subscription = client
        .listen(
          `*[_type == "post"]`,
          {}, // No parameters for this query
          {
            includeResult: true, // Include the full document in the event
            includePreviousRevision: false, // Don't include the previous version
            visibility: "query", // Only fire when the change is queryable
          },
        )
        .subscribe({
          next: (update) => {
            if (update.type === "mutation" && update.result) {
              // update.result contains the full document
              console.log("Updated document:", update.result.title);
            }
          },
          error: (err) => console.error(err),
        });
      ```
      
      **Why good:** `includeResult: true` gives the full document without a separate fetch, `visibility: "query"` ensures the event fires only when the change is visible to queries (consistent state)
      
      ### Important Caveats
      
      - **Projections are ignored** — `client.listen()` only uses the filter portion of a GROQ query. Projections, ordering, and slicing have no effect.
      - **Reconnection** — The listener automatically reconnects on disconnection, but events during the gap may be missed. For critical use cases, combine with periodic re-fetching.
      - **For production frontends** — Evaluate the newer Sanity Live Content API as a simpler alternative to raw listeners.
      
      ---
      
      _For client setup and GROQ, see [core.md](core.md). For schema definitions, see [schemas.md](schemas.md). For Portable Text and images, see [rich-content.md](rich-content.md)._
      
    • rich-content.md 7 KB
      # Sanity Rich Content Examples
      
      > Portable Text rendering with @portabletext/react and image URL builder with @sanity/image-url. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup.
      
      **Prerequisites**: Understand client setup and GROQ queries from core examples first.
      
      ---
      
      ## Pattern 1: Basic Portable Text Rendering
      
      ### Good Example — PortableText with Custom Components
      
      ```tsx
      import { PortableText } from "@portabletext/react";
      import type {
        PortableTextComponents,
        PortableTextBlock,
      } from "@portabletext/react";
      import { urlFor } from "../lib/sanity-image";
      
      const components: PortableTextComponents = {
        types: {
          image: ({ value }) => {
            if (!value?.asset?._ref) return null;
            return (
              <figure>
                <img
                  src={urlFor(value).width(800).auto("format").url()}
                  alt={value.alt || ""}
                  loading="lazy"
                />
                {value.caption && <figcaption>{value.caption}</figcaption>}
              </figure>
            );
          },
          code: ({ value }) => (
            <pre data-language={value.language}>
              <code>{value.code}</code>
            </pre>
          ),
          callout: ({ value }) => (
            <aside data-tone={value.tone}>
              <PortableText value={value.text} />
            </aside>
          ),
        },
        marks: {
          link: ({ children, value }) => {
            const isExternal = value.href && !value.href.startsWith("/");
            return (
              <a
                href={value.href}
                rel={isExternal ? "noopener noreferrer" : undefined}
                target={isExternal ? "_blank" : undefined}
              >
                {children}
              </a>
            );
          },
          highlight: ({ children }) => <mark>{children}</mark>,
          inlineCode: ({ children }) => <code>{children}</code>,
        },
        block: {
          h2: ({ children }) => <h2>{children}</h2>,
          h3: ({ children }) => <h3>{children}</h3>,
          blockquote: ({ children }) => <blockquote>{children}</blockquote>,
          normal: ({ children }) => <p>{children}</p>,
        },
        list: {
          bullet: ({ children }) => <ul>{children}</ul>,
          number: ({ children }) => <ol>{children}</ol>,
        },
      };
      
      function ArticleBody({ body }: { body: PortableTextBlock[] }) {
        return <PortableText value={body} components={components} />;
      }
      export { ArticleBody };
      ```
      
      **Why good:** Custom renderers for image, code, and callout block types; external links get security attributes; images use `urlFor` for optimized delivery; null check for missing image assets; lazy loading for performance; named export
      
      ### Bad Example — Using Deprecated Package
      
      ```tsx
      // BAD: @sanity/block-content-to-react is deprecated
      import BlockContent from "@sanity/block-content-to-react";
      
      // Uses old "serializers" API, unmaintained
      <BlockContent blocks={body} serializers={serializers} />;
      ```
      
      **Why bad:** `@sanity/block-content-to-react` is deprecated and unmaintained, replaced by `@portabletext/react` which uses "components" instead of "serializers"
      
      ---
      
      ## Pattern 2: Image URL Builder Setup
      
      ### Good Example — Reusable urlFor Helper
      
      ```typescript
      // lib/sanity-image.ts
      import { createImageUrlBuilder } from "@sanity/image-url";
      import type { SanityImageSource } from "@sanity/image-url";
      import { client } from "./sanity-client";
      
      const builder = createImageUrlBuilder(client);
      
      export function urlFor(source: SanityImageSource) {
        return builder.image(source);
      }
      ```
      
      **Why good:** `createImageUrlBuilder` reads projectId and dataset from the client config, `urlFor` returns a chainable builder, `SanityImageSource` type handles all image input formats (asset ref, full image object, URL)
      
      ### Bad Example — Manual URL Construction
      
      ```typescript
      // BAD: Manually constructing image URLs
      function getImageUrl(ref: string) {
        const [, id, dimensions, format] = ref.split("-");
        return `https://cdn.sanity.io/images/projectId/dataset/${id}-${dimensions}.${format}`;
      }
      ```
      
      **Why bad:** Hardcoded project/dataset, ignores crop and hotspot metadata, no format optimization, no responsive sizing, breaks if Sanity changes CDN URL format
      
      ---
      
      ## Pattern 3: Responsive Images
      
      ### Good Example — Responsive srcSet with Auto Format
      
      ```tsx
      import { urlFor } from "../lib/sanity-image";
      import type { SanityImageSource } from "@sanity/image-url";
      
      const WIDTHS = [400, 800, 1200, 1600] as const;
      const DEFAULT_WIDTH = 800;
      
      function ResponsiveImage({
        image,
        alt,
        sizes = "(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px",
      }: {
        image: SanityImageSource;
        alt: string;
        sizes?: string;
      }) {
        return (
          <img
            src={urlFor(image).width(DEFAULT_WIDTH).auto("format").url()}
            srcSet={WIDTHS.map(
              (w) => `${urlFor(image).width(w).auto("format").url()} ${w}w`,
            ).join(", ")}
            sizes={sizes}
            alt={alt}
            loading="lazy"
          />
        );
      }
      export { ResponsiveImage };
      ```
      
      **Why good:** Named constants for widths, `.auto("format")` serves WebP/AVIF when the browser supports it, `srcSet` with width descriptors for responsive delivery, `sizes` attribute guides browser selection, lazy loading, crop and hotspot metadata automatically applied when passing the full image field
      
      ---
      
      ## Pattern 4: Image Transformations
      
      ### Good Example — Common Transformation Patterns
      
      ```typescript
      import { urlFor } from "../lib/sanity-image";
      
      // Fixed-size thumbnail
      const thumbnailUrl = urlFor(image).width(150).height(150).fit("crop").url();
      
      // Aspect ratio crop
      const heroUrl = urlFor(image)
        .width(1200)
        .height(400)
        .fit("crop")
        .auto("format")
        .url();
      
      // Blur placeholder (for progressive loading)
      const blurUrl = urlFor(image).width(20).blur(50).url();
      
      // Specific format
      const pngUrl = urlFor(image).width(800).format("png").url();
      
      // Quality control
      const compressedUrl = urlFor(image).width(800).quality(75).auto("format").url();
      ```
      
      **Why good:** `.fit("crop")` respects hotspot when cropping, low-res blur for placeholder loading patterns, `.auto("format")` for automatic format selection, `.quality()` for size/quality tradeoff
      
      ---
      
      ## Pattern 5: Querying Portable Text as Plain Text
      
      ### Good Example — Using pt::text() in GROQ
      
      ```typescript
      import { defineQuery } from "groq";
      
      // Extract plain text from Portable Text for excerpts and search
      const POSTS_WITH_EXCERPT_QUERY = defineQuery(`
        *[_type == "post" && published == true]{
          _id,
          title,
          "slug": slug.current,
          "excerpt": pt::text(body)[0...200],
          publishedAt
        } | order(publishedAt desc)
      `);
      
      // Full-text search across Portable Text content
      const SEARCH_POSTS_QUERY = defineQuery(`
        *[_type == "post" && pt::text(body) match $searchTerm]{
          _id,
          title,
          "slug": slug.current,
          "excerpt": pt::text(body)[0...200]
        }
      `);
      
      const results = await client.fetch(SEARCH_POSTS_QUERY, {
        searchTerm: "typescript*",
      });
      ```
      
      **Why good:** `pt::text()` converts Portable Text to plain text within GROQ (no client-side processing), string slicing `[0...200]` creates excerpts server-side, `match` operator for full-text search with wildcard support, parameterized search term prevents injection
      
      ---
      
      _For schema definitions, see [schemas.md](schemas.md). For mutations and real-time, see [mutations.md](mutations.md)._
      
    • schemas.md 9.6 KB
      # Sanity Schema Examples
      
      > Schema definitions with defineType, defineField, defineArrayMember. See [SKILL.md](../SKILL.md) for core concepts and [core.md](core.md) for client setup.
      
      **Prerequisites**: Understand client setup from core examples first.
      
      ---
      
      ## Pattern 1: Document Type with Validation
      
      ### Good Example — Complete Document Schema
      
      ```typescript
      // 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(5).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 },
            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",
          }),
          defineField({
            name: "body",
            title: "Body",
            type: "array",
            of: [
              defineArrayMember({ type: "block" }),
              defineArrayMember({
                type: "image",
                options: { hotspot: true },
                fields: [
                  defineField({
                    name: "alt",
                    title: "Alt Text",
                    type: "string",
                  }),
                  defineField({
                    name: "caption",
                    title: "Caption",
                    type: "string",
                  }),
                ],
              }),
              defineArrayMember({
                type: "object",
                name: "code",
                title: "Code Block",
                fields: [
                  defineField({
                    name: "language",
                    title: "Language",
                    type: "string",
                  }),
                  defineField({ name: "code", title: "Code", type: "text" }),
                ],
              }),
            ],
          }),
        ],
        preview: {
          select: {
            title: "title",
            author: "author.name",
            media: "mainImage",
          },
          prepare(selection) {
            const { author } = selection;
            return {
              ...selection,
              subtitle: author ? `by ${author}` : "No author",
            };
          },
        },
        orderings: [
          {
            title: "Published Date, New",
            name: "publishedAtDesc",
            by: [{ field: "publishedAt", direction: "desc" }],
          },
        ],
      });
      ```
      
      **Why good:** `defineType`/`defineField`/`defineArrayMember` provide IDE autocomplete, validation rules enforce content quality, `hotspot: true` enables focal point cropping, alt text required on images for accessibility, body supports blocks (rich text), images, and custom code blocks, preview customizes Studio list appearance, orderings define sort options in Studio
      
      ---
      
      ## Pattern 2: Object Types (Reusable, Non-Document)
      
      ### Good Example — Reusable Object Schema
      
      ```typescript
      // schemas/objects/seo.ts
      import { defineType, defineField } from "sanity";
      
      const SEO_TITLE_MAX_LENGTH = 60;
      const SEO_DESCRIPTION_MAX_LENGTH = 160;
      
      export const seoType = defineType({
        name: "seo",
        title: "SEO",
        type: "object",
        fields: [
          defineField({
            name: "metaTitle",
            title: "Meta Title",
            type: "string",
            validation: (rule) => rule.max(SEO_TITLE_MAX_LENGTH),
            description: `Max ${SEO_TITLE_MAX_LENGTH} characters for search engines`,
          }),
          defineField({
            name: "metaDescription",
            title: "Meta Description",
            type: "text",
            rows: 3,
            validation: (rule) => rule.max(SEO_DESCRIPTION_MAX_LENGTH),
            description: `Max ${SEO_DESCRIPTION_MAX_LENGTH} characters`,
          }),
          defineField({
            name: "ogImage",
            title: "Open Graph Image",
            type: "image",
            description: "Recommended: 1200x630px",
          }),
        ],
      });
      ```
      
      **Why good:** Object type (not document) — can be embedded in any document via `type: "seo"`, named constants for magic numbers, `description` guides editors, `rows: 3` on text field improves Studio UX
      
      ### Usage in a Document
      
      ```typescript
      // In a page document
      defineField({
        name: "seo",
        title: "SEO Settings",
        type: "seo", // References the object type defined above
      }),
      ```
      
      ---
      
      ## Pattern 3: Reference Fields
      
      ### Good Example — Reference with Filtering
      
      ```typescript
      defineField({
        name: "author",
        title: "Author",
        type: "reference",
        to: [{ type: "author" }],
        validation: (rule) => rule.required(),
      }),
      
      // Reference to multiple types (polymorphic)
      defineField({
        name: "relatedContent",
        title: "Related Content",
        type: "array",
        of: [
          defineArrayMember({
            type: "reference",
            to: [{ type: "post" }, { type: "page" }, { type: "product" }],
          }),
        ],
      }),
      
      // Reference with filter (only show published authors)
      defineField({
        name: "reviewer",
        title: "Reviewer",
        type: "reference",
        to: [{ type: "author" }],
        options: {
          filter: "active == true",
          filterParams: {},
        },
      }),
      ```
      
      **Why good:** Single reference with validation, polymorphic reference allows multiple target types, `options.filter` limits reference picker in Studio to matching documents
      
      ---
      
      ## Pattern 4: Image and File Fields
      
      ### Good Example — Image with Metadata
      
      ```typescript
      defineField({
        name: "mainImage",
        title: "Main Image",
        type: "image",
        options: {
          hotspot: true, // Enables focal point selection in Studio
        },
        fields: [
          defineField({
            name: "alt",
            title: "Alternative Text",
            type: "string",
            validation: (rule) =>
              rule.custom((alt, context) => {
                // Require alt text when an image is set
                const parent = context.parent as { asset?: { _ref?: string } };
                if (parent?.asset?._ref && !alt) {
                  return "Alt text is required when an image is set";
                }
                return true;
              }),
          }),
          defineField({
            name: "caption",
            title: "Caption",
            type: "string",
          }),
        ],
      }),
      
      // File field for downloads
      defineField({
        name: "attachment",
        title: "Attachment",
        type: "file",
        options: {
          accept: ".pdf,.doc,.docx",
        },
        fields: [
          defineField({
            name: "description",
            title: "Description",
            type: "string",
          }),
        ],
      }),
      ```
      
      **Why good:** `hotspot: true` lets editors choose a focal point that `@sanity/image-url` respects during cropping, custom validation requires alt text only when an image is actually uploaded, file field restricts upload types with `accept`
      
      ---
      
      ## Pattern 5: Array Fields with Constraints
      
      ### Good Example — Array with Min/Max and Unique Items
      
      ```typescript
      defineField({
        name: "tags",
        title: "Tags",
        type: "array",
        of: [defineArrayMember({ type: "string" })],
        validation: (rule) => rule.unique().min(1).max(10),
        options: {
          layout: "tags", // Renders as tag chips in Studio
        },
      }),
      
      // Array of objects with constrained block types
      defineField({
        name: "content",
        title: "Content",
        type: "array",
        of: [
          defineArrayMember({ type: "block" }),
          defineArrayMember({
            type: "image",
            options: { hotspot: true },
          }),
          defineArrayMember({
            type: "object",
            name: "callout",
            title: "Callout",
            fields: [
              defineField({
                name: "tone",
                title: "Tone",
                type: "string",
                options: {
                  list: [
                    { title: "Info", value: "info" },
                    { title: "Warning", value: "warning" },
                    { title: "Tip", value: "tip" },
                  ],
                },
              }),
              defineField({
                name: "text",
                title: "Text",
                type: "array",
                of: [defineArrayMember({ type: "block" })],
              }),
            ],
            preview: {
              select: { tone: "tone" },
              prepare({ tone }) {
                return { title: `Callout: ${tone || "default"}` };
              },
            },
          }),
        ],
      }),
      ```
      
      **Why good:** `.unique()` prevents duplicate tags, `layout: "tags"` improves Studio editing UX, custom inline objects (callout) have their own preview, content array mixes blocks, images, and custom types for flexible editorial experiences
      
      ---
      
      ## Pattern 6: Schema Registration
      
      ### Good Example — Registering Schemas in sanity.config.ts
      
      ```typescript
      // sanity.config.ts
      import { defineConfig } from "sanity";
      import { structureTool } from "sanity/structure";
      import { postType } from "./schemas/post";
      import { authorType } from "./schemas/author";
      import { categoryType } from "./schemas/category";
      import { seoType } from "./schemas/objects/seo";
      
      // NOTE: default export required by Sanity Studio
      export default defineConfig({
        name: "default",
        title: "My Project",
        projectId: process.env.SANITY_PROJECT_ID!,
        dataset: process.env.SANITY_DATASET!,
        plugins: [structureTool()],
        schema: {
          types: [postType, authorType, categoryType, seoType],
        },
      });
      ```
      
      **Why good:** Each schema in its own file, imported and registered in config, `structureTool()` provides the default document editor, schema types array includes both document types and reusable object types
      
      ---
      
      _For client and GROQ patterns, see [core.md](core.md). For Portable Text and images, see [rich-content.md](rich-content.md)._
      
  • reference.md 10.9 KB
    # Sanity Reference
    
    > CLI commands, GROQ cheat sheet, type helpers, and quick-lookup tables. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples.
    
    ---
    
    ## Sanity CLI Commands
    
    ### Project Setup
    
    ```bash
    # Install Sanity CLI globally (or use npx)
    npm install -g sanity
    
    # Initialize a new Sanity project
    npx sanity init
    
    # Start Sanity Studio (local dev)
    npx sanity dev
    
    # Build Studio for deployment
    npx sanity build
    
    # Deploy Studio to Sanity hosting
    npx sanity deploy
    ```
    
    ### Type Generation
    
    ```bash
    # Extract schema to JSON
    npx sanity schema extract
    # Creates: schema.json
    
    # Generate TypeScript types from schema + GROQ queries
    npx sanity typegen generate
    
    # Watch mode (re-generates on changes)
    npx sanity typegen generate --watch
    ```
    
    ### Dataset Management
    
    ```bash
    # List datasets
    npx sanity dataset list
    
    # Create a dataset
    npx sanity dataset create <name>
    
    # Export dataset
    npx sanity dataset export <dataset> <output-file>
    
    # Import dataset
    npx sanity dataset import <input-file> <dataset>
    ```
    
    ### CORS Management
    
    ```bash
    # List CORS origins
    npx sanity cors list
    
    # Add a CORS origin
    npx sanity cors add http://localhost:3000
    
    # Delete a CORS origin
    npx sanity cors delete http://localhost:3000
    ```
    
    ---
    
    ## Environment Variables
    
    ```bash
    # .env.local
    SANITY_PROJECT_ID=your-project-id
    SANITY_DATASET=production
    SANITY_API_TOKEN=sk...          # Server-only — never expose to client
    ```
    
    ---
    
    ## GROQ Quick Reference
    
    ### Filters
    
    | Pattern        | GROQ                                                 |
    | -------------- | ---------------------------------------------------- |
    | All of type    | `*[_type == "post"]`                                 |
    | Multiple types | `*[_type in ["post", "page"]]`                       |
    | By slug        | `*[_type == "post" && slug.current == $slug][0]`     |
    | By reference   | `*[_type == "post" && references($authorId)]`        |
    | Boolean        | `*[_type == "post" && published == true]`            |
    | Comparison     | `*[_type == "post" && publishedAt > "2024-01-01"]`   |
    | Pattern match  | `*[_type == "post" && title match "sanity*"]`        |
    | Array contains | `*[_type == "post" && "typescript" in tags]`         |
    | Has field      | `*[_type == "post" && defined(featuredImage)]`       |
    | Combined (AND) | `*[_type == "post" && published && count(tags) > 0]` |
    | Combined (OR)  | `*[published == true \|\| _id in path("drafts.**")]` |
    
    ### Projections
    
    | Pattern             | GROQ                                       |
    | ------------------- | ------------------------------------------ |
    | Specific fields     | `{ _id, title, slug }`                     |
    | Rename field        | `{ "postTitle": title }`                   |
    | All fields          | `{ ... }`                                  |
    | All + computed      | `{ ..., "excerpt": pt::text(body) }`       |
    | Dereference         | `{ "author": author->{ name, image } }`    |
    | Array dereference   | `{ "tags": tags[]->{ title } }`            |
    | Nested array fields | `{ content[]{ _type, _key } }`             |
    | Conditional fields  | `{ ..., _type == "post" => { body } }`     |
    | Coalesce            | `{ "name": coalesce(displayName, email) }` |
    | Count               | `{ "tagCount": count(tags) }`              |
    
    ### Ordering and Slicing
    
    | Pattern         | GROQ                                      |
    | --------------- | ----------------------------------------- |
    | Ascending       | `\| order(publishedAt asc)`               |
    | Descending      | `\| order(publishedAt desc)`              |
    | Multi-field     | `\| order(priority desc, _createdAt asc)` |
    | First item      | `[0]`                                     |
    | First N items   | `[0...10]` (non-inclusive end)            |
    | Inclusive range | `[0..9]` (inclusive end)                  |
    | Offset          | `[10...20]`                               |
    | Parameterized   | `[$start...$end]`                         |
    
    ### Joins and References
    
    | Pattern             | GROQ                                                        |
    | ------------------- | ----------------------------------------------------------- |
    | Follow reference    | `author->{ name }`                                          |
    | Nested reference    | `image{ asset->{ url } }`                                   |
    | Array of references | `categories[]->{ title }`                                   |
    | Reverse reference   | `"posts": *[_type == "post" && references(^._id)]{ title }` |
    | Filter by reference | `*[references("author-id")]`                                |
    
    ### Special Variables
    
    | Variable | Meaning                                                |
    | -------- | ------------------------------------------------------ |
    | `*`      | All documents in the dataset                           |
    | `@`      | Current document in scope                              |
    | `^`      | Parent document (used in subqueries)                   |
    | `$param` | Query parameter (passed via `client.fetch` second arg) |
    
    ### Built-in Functions
    
    | Function                              | Description                              |
    | ------------------------------------- | ---------------------------------------- |
    | `count(array)`                        | Number of elements in array              |
    | `defined(field)`                      | True if field has a value                |
    | `coalesce(a, b, ...)`                 | First non-null value                     |
    | `round(num)` / `round(num, decimals)` | Round a number                           |
    | `lower(str)` / `upper(str)`           | Case conversion                          |
    | `pt::text(portableText)`              | Convert Portable Text to plain text      |
    | `array::unique(arr)`                  | Remove duplicates                        |
    | `array::compact(arr)`                 | Remove null values                       |
    | `array::join(arr, separator)`         | Join array to string                     |
    | `references(id)`                      | True if document references the given ID |
    | `select(cond => val, ...)`            | Conditional value selection              |
    | `score(expr)`                         | Scoring for search relevance             |
    
    ---
    
    ## Schema Field Types
    
    | Type        | Description                                   |
    | ----------- | --------------------------------------------- |
    | `string`    | Plain text string                             |
    | `text`      | Multi-line text (no formatting)               |
    | `number`    | Numeric value                                 |
    | `boolean`   | True/false                                    |
    | `date`      | Date without time                             |
    | `datetime`  | Date with time                                |
    | `slug`      | URL-safe string (accessed via `.current`)     |
    | `url`       | Validated URL string                          |
    | `email`     | Validated email string                        |
    | `image`     | Image with optional hotspot/crop              |
    | `file`      | Uploaded file                                 |
    | `array`     | Array of items (requires `of` property)       |
    | `object`    | Inline object (not a document)                |
    | `block`     | Portable Text block (rich text)               |
    | `reference` | Reference to another document (requires `to`) |
    | `geopoint`  | Latitude/longitude point                      |
    
    ---
    
    ## Image URL Builder Methods
    
    | Method              | Description                                                           |
    | ------------------- | --------------------------------------------------------------------- |
    | `.width(px)`        | Set width in pixels                                                   |
    | `.height(px)`       | Set height in pixels                                                  |
    | `.size(w, h)`       | Set both width and height                                             |
    | `.fit(mode)`        | Resize mode: `clip`, `crop`, `fill`, `fillmax`, `max`, `scale`, `min` |
    | `.auto(type)`       | Auto-format: `'format'` serves WebP/AVIF when supported               |
    | `.format(fmt)`      | Force format: `jpg`, `pjpg`, `png`, `webp`                            |
    | `.quality(q)`       | JPEG/WebP quality (0-100)                                             |
    | `.blur(amount)`     | Apply Gaussian blur                                                   |
    | `.sharpen(amount)`  | Apply sharpening                                                      |
    | `.flipHorizontal()` | Flip horizontally                                                     |
    | `.flipVertical()`   | Flip vertically                                                       |
    | `.rect(x, y, w, h)` | Crop to specific rectangle                                            |
    | `.focalPoint(x, y)` | Set focal point (0.0-1.0)                                             |
    | `.url()`            | Return the final URL string                                           |
    
    ---
    
    ## Portable Text Component Types
    
    | Component Key | Purpose                                                |
    | ------------- | ------------------------------------------------------ |
    | `types`       | Custom block types (image, code, callout, video embed) |
    | `marks`       | Inline annotations (link, highlight, footnote)         |
    | `block`       | Block-level styles (h1-h6, blockquote, normal)         |
    | `list`        | List types (bullet, number)                            |
    | `listItem`    | List item rendering                                    |
    | `hardBreak`   | Line break rendering                                   |
    
    ---
    
    ## Client Methods Quick Reference
    
    | Method                                  | Description                                         |
    | --------------------------------------- | --------------------------------------------------- |
    | `client.fetch(query, params?)`          | Execute a GROQ query                                |
    | `client.create(doc)`                    | Create a new document (auto-generates `_id`)        |
    | `client.createOrReplace(doc)`           | Create or fully replace (requires `_id`)            |
    | `client.createIfNotExists(doc)`         | Create only if `_id` doesn't exist                  |
    | `client.patch(id).set({}).commit()`     | Update specific fields                              |
    | `client.patch(id).unset([]).commit()`   | Remove fields                                       |
    | `client.patch(id).inc({}).commit()`     | Increment numeric fields                            |
    | `client.patch(id).dec({}).commit()`     | Decrement numeric fields                            |
    | `client.patch(id).insert(...).commit()` | Insert into arrays                                  |
    | `client.delete(id)`                     | Delete a document                                   |
    | `client.transaction()...commit()`       | Group mutations atomically                          |
    | `client.listen(query)`                  | Subscribe to real-time changes (returns Observable) |
    | `client.getDocument(id)`                | Fetch a single document by ID                       |
    
  • SKILL.md 16.6 KB
    ---
    name: api-cms-sanity
    description: Structured content platform — GROQ queries, schema definitions, @sanity/client, Portable Text, image handling, real-time listeners, mutations, TypeGen
    ---
    
    # Sanity Patterns
    
    > **Quick Guide:** Use Sanity for structured content management with GROQ queries, typed schemas via `defineType`/`defineField`, and `@sanity/client` for data fetching. Always set `apiVersion` to a dated string, use `useCdn: true` for public reads, handle draft documents explicitly, use `@sanity/image-url` for image transformations, and render rich text with `@portabletext/react`. Generate TypeScript types with `sanity typegen generate`.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST always set `apiVersion` on `createClient` to a dated string like `'2025-02-19'` — omitting it uses a legacy API that may break)**
    
    **(You MUST use `useCdn: true` for public read queries and `useCdn: false` when using a token or needing fresh data)**
    
    **(You MUST use parameterized GROQ queries (`$param`) for any dynamic values — never interpolate user input into GROQ strings)**
    
    **(You MUST handle drafts explicitly — draft documents have `_id` prefixed with `drafts.` and are not returned by default with `perspective: 'published'`)**
    
    **(You MUST use `defineQuery()` from `groq` and assign queries to named variables for TypeGen type generation)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** Sanity, sanity, @sanity/client, createClient, GROQ, groq, defineType, defineField, defineArrayMember, @sanity/image-url, urlFor, @portabletext/react, PortableText, portable text, block content, sanity.config, sanity.cli, typegen, sanity studio, content lake
    
    **When to use:**
    
    - Setting up `@sanity/client` with `createClient` for data fetching
    - Writing GROQ queries (filters, projections, joins, ordering, slicing)
    - Defining content schemas with `defineType`, `defineField`, `defineArrayMember`
    - Rendering Portable Text (block content) with `@portabletext/react`
    - Generating image URLs with `@sanity/image-url` (responsive images, crops, hotspots)
    - Performing mutations (create, patch, delete, transactions)
    - Setting up real-time listeners with `client.listen()`
    - Generating TypeScript types with Sanity TypeGen
    
    **Key patterns covered:**
    
    - Client setup with `createClient` and `apiVersion` configuration
    - GROQ query language: filters, projections, ordering, slicing, joins, references
    - Schema definitions: document types, object types, arrays, references, images
    - Portable Text rendering with custom components
    - Image URL builder with responsive images and transformations
    - Mutations: create, createOrReplace, patch, delete, transactions
    - Real-time listeners via `client.listen()`
    - TypeGen for type-safe GROQ queries with `defineQuery()`
    
    **When NOT to use:**
    
    - GraphQL-only APIs (Sanity supports GROQ primarily; use GraphQL skill if needed)
    - Direct database access (Sanity is a hosted content lake, not a database)
    - Non-Sanity CMS platforms (use the dedicated skill for your CMS)
    
    **Detailed Resources:**
    
    - For decision frameworks and quick-reference tables, see [reference.md](reference.md)
    
    **Client & GROQ:**
    
    - [examples/core.md](examples/core.md) — Client setup, GROQ queries, error handling, TypeGen
    
    **Schemas:**
    
    - [examples/schemas.md](examples/schemas.md) — defineType, defineField, document types, object types, references, images
    
    **Rich Content:**
    
    - [examples/rich-content.md](examples/rich-content.md) — Portable Text rendering, image URL builder, responsive images
    
    **Mutations & Real-time:**
    
    - [examples/mutations.md](examples/mutations.md) — Create, patch, delete, transactions, real-time listeners
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    Sanity is a structured content platform built around a real-time content lake, GROQ (Graph-Relational Object Queries) as its query language, and Sanity Studio as a customizable editing environment.
    
    **Core principles:**
    
    1. **Structured content** — Content is defined by schemas (`defineType`, `defineField`) that describe shape, validation, and editorial UI. Schemas are code, not configuration files.
    2. **GROQ-first querying** — GROQ lets you filter, project, join, and reshape data in a single query. Unlike REST or GraphQL, GROQ queries return exactly the shape you define in the projection.
    3. **Content as data** — Rich text is stored as Portable Text (a JSON-based specification), making it renderable in any frontend framework without vendor lock-in.
    4. **API versioning** — Every client must specify an `apiVersion` date string. This pins your code to a specific API behavior, preventing breaking changes from affecting production.
    5. **CDN caching** — Public read queries use `useCdn: true` for edge-cached responses. Mutations and authenticated reads use `useCdn: false` for fresh data.
    6. **Type generation** — Sanity TypeGen generates TypeScript types from both your schemas and GROQ queries, enabling end-to-end type safety from content model to frontend.
    
    **When to use Sanity:**
    
    - Content-driven websites and applications (blogs, marketing sites, documentation)
    - Projects needing real-time collaborative editing in a customizable studio
    - Multi-channel content delivery (web, mobile, IoT) from a single content source
    - Teams wanting type-safe content queries with GROQ and TypeGen
    
    **When NOT to use:**
    
    - Transactional data requiring ACID guarantees (use a database)
    - User-generated content at massive scale (Sanity is optimized for editorial content)
    - Projects needing a self-hosted CMS (Sanity's content lake is hosted, though the Studio is open source)
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: Client Setup with createClient
    
    Configure `@sanity/client` with project ID, dataset, API version, and CDN preference. Always set `apiVersion` to a dated string and `useCdn` explicitly.
    
    ```typescript
    import { createClient } from "@sanity/client";
    
    export const client = createClient({
      projectId: process.env.SANITY_PROJECT_ID!,
      dataset: process.env.SANITY_DATASET!,
      apiVersion: "2025-02-19", // Pin to a specific API version date
      useCdn: true, // true for public reads, false for authenticated/fresh data
    });
    ```
    
    For dual client setup (public + preview with token), see [examples/core.md](examples/core.md).
    
    ---
    
    ### Pattern 2: GROQ Queries with Filters and Projections
    
    GROQ queries combine filters, projections, ordering, and slicing. Always use `defineQuery()` for TypeGen and `$param` for dynamic values.
    
    ```typescript
    import { defineQuery } from "groq";
    
    const POST_BY_SLUG_QUERY = defineQuery(`
      *[_type == "post" && slug.current == $slug][0]{
        _id, title, body, "author": author->{name, image}
      }
    `);
    const post = await client.fetch(POST_BY_SLUG_QUERY, { slug: "my-post" });
    ```
    
    Never interpolate user input into GROQ strings -- always use `$param` parameters to prevent GROQ injection. For advanced queries (combined queries, conditional projections), see [examples/core.md](examples/core.md).
    
    ---
    
    ### Pattern 3: Schema Definitions with defineType and defineField
    
    Define content structure with `defineType`, `defineField`, and `defineArrayMember` from `"sanity"` for type safety and Studio UI.
    
    ```typescript
    import { defineType, defineField } from "sanity";
    
    export const postType = defineType({
      name: "post",
      type: "document",
      fields: [
        defineField({
          name: "title",
          type: "string",
          validation: (r) => r.required(),
        }),
        defineField({ name: "slug", type: "slug", options: { source: "title" } }),
      ],
    });
    ```
    
    For complete schemas (images with hotspot, arrays, references, previews, object types), see [examples/schemas.md](examples/schemas.md).
    
    ---
    
    ### Pattern 4: Portable Text Rendering
    
    Render block content with `@portabletext/react`. Define custom `PortableTextComponents` for non-standard blocks (images, code) and marks (links, highlights).
    
    ```tsx
    import { PortableText } from "@portabletext/react";
    <PortableText value={body} components={components} />;
    ```
    
    Do not use the deprecated `@sanity/block-content-to-react` package. For full component examples, see [examples/rich-content.md](examples/rich-content.md).
    
    ---
    
    ### Pattern 5: Image URL Builder
    
    Use `@sanity/image-url` to generate optimized, responsive image URLs with crop and hotspot support.
    
    ```typescript
    import { createImageUrlBuilder } from "@sanity/image-url";
    const builder = createImageUrlBuilder(client);
    export function urlFor(source: SanityImageSource) {
      return builder.image(source);
    }
    // Usage: urlFor(image).width(800).auto("format").url()
    ```
    
    For responsive `srcSet` patterns and image transformations, see [examples/rich-content.md](examples/rich-content.md).
    
    ---
    
    ### Pattern 6: Mutations (Create, Patch, Delete)
    
    Use `@sanity/client` methods for document mutations. Always call `.commit()` on patches and transactions.
    
    ```typescript
    await client.create({ _type: "post", title: "New Post" });
    await client.patch("post-123").set({ title: "Updated" }).commit();
    await client.delete("post-123");
    ```
    
    For `createOrReplace`, `createIfNotExists`, transactions, array inserts, and visibility options, see [examples/mutations.md](examples/mutations.md).
    
    ---
    
    ### Pattern 7: Real-Time Listeners
    
    Subscribe to document changes with `client.listen()`. The listener only uses the filter portion of GROQ -- projections and ordering are ignored.
    
    ```typescript
    const subscription = client.listen(`*[_type == "post"]`).subscribe({
      next: (update) => {
        /* update.transition: 'update' | 'appear' | 'disappear' */
      },
      error: (err) => console.error(err),
    });
    subscription.unsubscribe(); // Cleanup when done
    ```
    
    For production frontends, evaluate the newer Live Content API as a simpler alternative. For listener options and caveats, see [examples/mutations.md](examples/mutations.md).
    
    ---
    
    ### Pattern 8: TypeGen for Type-Safe GROQ
    
    Configure TypeGen in `sanity.cli.ts` with `overloadClientMethods: true` for typed `client.fetch` results. Use `defineQuery()` from `"groq"` to make queries discoverable by TypeGen.
    
    ```typescript
    // sanity.cli.ts — set typegen.overloadClientMethods: true
    // queries/post-queries.ts — wrap queries with defineQuery()
    // sanity.types.ts — auto-generated result types
    const posts = await client.fetch(allPostsQuery); // Typed result
    ```
    
    Inline query strings without `defineQuery()` produce untyped (`any`) results. For full TypeGen configuration and workflow, see [examples/core.md](examples/core.md).
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### useCdn: true vs false
    
    ```
    Is the data public and non-personalized?
    ├─ YES → useCdn: true (edge-cached, fast)
    └─ NO →
        ├─ Using a token for authenticated reads? → useCdn: false
        ├─ Need real-time fresh data (preview)? → useCdn: false
        └─ Performing mutations? → useCdn: false
    ```
    
    ### Draft Handling
    
    ```
    Do you need draft documents?
    ├─ YES → Use perspective: 'previewDrafts' (requires token)
    ├─ NO → Use perspective: 'published' (default since API v2025-02-19)
    └─ Mixed (preview mode toggle)?
        └─ Create two clients: one public (useCdn: true), one preview (token + useCdn: false)
    ```
    
    ### GROQ Query Return Shape
    
    ```
    How many documents do you expect?
    ├─ One (by ID, slug, singleton) → Add [0] at end (returns object or null)
    ├─ Many (list, feed) → No slice suffix (returns array)
    └─ Paginated → Add [start...end] slice
    ```
    
    ### Image Handling
    
    ```
    How should images be delivered?
    ├─ Fixed size (thumbnails, avatars) → urlFor(img).width(W).height(H).url()
    ├─ Responsive (article images) → srcSet with multiple widths
    ├─ Format optimization → .auto('format') for WebP/AVIF
    └─ Cropped to aspect ratio → .width(W).height(H).fit('crop')
    ```
    
    ### Mutation Method Selection
    
    ```
    What operation do you need?
    ├─ Create new document → client.create()
    ├─ Create or fully replace → client.createOrReplace() (for singletons)
    ├─ Create only if missing → client.createIfNotExists()
    ├─ Update specific fields → client.patch(id).set({...}).commit()
    ├─ Remove fields → client.patch(id).unset([...]).commit()
    ├─ Multiple related changes → client.transaction()...commit()
    └─ Delete document → client.delete(id)
    ```
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - **Missing `apiVersion` on client** — Omitting `apiVersion` uses legacy API behavior that may change without notice. Always pin to a date string.
    - **String interpolation in GROQ queries** — Interpolating user input into GROQ strings enables GROQ injection. Always use `$param` parameters.
    - **Using `useCdn: true` with a token** — CDN-cached responses ignore authentication tokens. Authenticated queries must use `useCdn: false`.
    - **Accessing draft documents without a token** — Drafts (`drafts.*` documents) require an API token and `perspective: 'previewDrafts'`. Without a token, drafts are invisible.
    
    **Medium Priority Issues:**
    
    - **Fetching all fields with `{...}` when only a few are needed** — Over-fetching wastes bandwidth and CDN cache efficiency. Project only the fields you need.
    - **Missing `.commit()` on patches** — `client.patch(id).set({...})` without `.commit()` does nothing — the mutation is never sent.
    - **Not using `defineQuery()` for GROQ queries** — TypeGen cannot generate types for queries that aren't wrapped in `defineQuery()` or assigned to named variables.
    - **Hardcoded project ID or dataset** — Use environment variables; hardcoded values prevent environment switching and leak project details.
    - **Using deprecated `@sanity/block-content-to-react`** — Replaced by `@portabletext/react`. The old package is unmaintained.
    
    **Common Mistakes:**
    
    - **Forgetting `_key` on array items in mutations** — Every item in a Sanity array must have a unique `_key` field. Mutations without `_key` will fail.
    - **Using `_id` with `client.create()`** — `create()` generates a random `_id`. If you specify `_id` and the document exists, it errors. Use `createOrReplace()` or `createIfNotExists()` for idempotent operations.
    - **Not handling the case where `[0]` returns `null`** — A GROQ query ending in `[0]` returns `null` if no documents match, not an empty object.
    - **Expecting `client.listen()` to work with projections** — The listener only uses the filter portion of a GROQ query. Projections, ordering, and slicing are ignored.
    
    **Gotchas & Edge Cases:**
    
    - **API version `2025-02-19` changed default perspective** — Before this version, the default perspective was `raw` (includes drafts). After, it defaults to `published`. Existing code may break if you update `apiVersion` without accounting for this.
    - **CDN cache is eventual** — After a mutation, CDN-cached queries (`useCdn: true`) may return stale data for a few seconds. Use `useCdn: false` or add a small delay for consistency-critical reads after writes.
    - **Slug fields store value in `.current`** — Query `slug.current`, not `slug` directly. `*[slug == "my-slug"]` will never match.
    - **Image fields require the full object for crop/hotspot** — Passing only `asset._ref` to `urlFor()` works for basic URLs but loses crop and hotspot metadata. Pass the entire image field object.
    - **Portable Text arrays need `_key` on every block** — When creating Portable Text content programmatically, every block and inline object needs a unique `_key`.
    - **`client.listen()` reconnects automatically** — But there's no built-in guarantee against missed events during reconnection. For critical use cases, combine with periodic re-fetching.
    - **TypeGen requires `schema.json` extraction first** — Run `npx sanity schema extract` before `npx sanity typegen generate`. The extract step reads your Studio schemas and outputs a JSON representation.
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST always set `apiVersion` on `createClient` to a dated string like `'2025-02-19'` — omitting it uses a legacy API that may break)**
    
    **(You MUST use `useCdn: true` for public read queries and `useCdn: false` when using a token or needing fresh data)**
    
    **(You MUST use parameterized GROQ queries (`$param`) for any dynamic values — never interpolate user input into GROQ strings)**
    
    **(You MUST handle drafts explicitly — draft documents have `_id` prefixed with `drafts.` and are not returned by default with `perspective: 'published'`)**
    
    **(You MUST use `defineQuery()` from `groq` and assign queries to named variables for TypeGen type generation)**
    
    **Failure to follow these rules will cause unpredictable API behavior, GROQ injection vulnerabilities, and untyped query results.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related