After shipping multiple production sites with Sanity CMS, I've learned these lessons the hard way. Here are the gotchas that cost me hours (sometimes days) of debugging.

1. The CDN Configuration Trap

The Gotcha

// ❌ This looks innocent but breaks preview
const sanity = createClient({
    projectId: '...',
    dataset: 'production',
    useCdn: false // Seems like a good idea for "fresh data"
});

The Problem: With useCdn: false, you're hitting the origin server directly on every request. This is:

  • Slower (no CDN edge caching)
  • More expensive (increased API usage)
  • Harder on Sanity's infrastructure
  • Unnecessary 99% of the time

The Solution

// ✅ Correct approach
const sanity = createClient({
    projectId: '...',
    dataset: 'production',
    useCdn: true, // Always true...
    perspective: 'published' // ...except in preview mode
});

// For preview mode, use a separate client
const previewClient = createClient({
    projectId: '...',
    dataset: 'production',
    useCdn: false, // Only false here
    perspective: 'previewDrafts'
});

Key Insight: The CDN updates within seconds. You don't need useCdn: false for "fresh" data. You need it ONLY for previewing unpublished drafts.

2. Preview Mode Environment Setup

The Gotcha

Setting up preview mode directly on production can cause:

  • Draft content accidentally visible to users
  • CDN caching draft content
  • Confusion about what's published vs unpublished

The Solution

Always use a staging environment for preview:

// nuxt.config.js
export default defineNuxtConfig({
    runtimeConfig: {
        public: {
            sanityUseCdn: process.env.NODE_ENV === 'production' && !process.env.PREVIEW_MODE
        }
    }
});

// In your Sanity client
const sanity = createClient({
    projectId: '...',
    dataset: 'production',
    useCdn: config.public.sanityUseCdn
});

Workflow:

  1. ProductionuseCdn: true, published content only
  2. StaginguseCdn: false, preview mode enabled
  3. Preview links → Point to staging, never production

AWS/Vercel Setup:

  • Production: marcusjh.co.uk
  • Staging: staging.marcusjh.co.uk (preview mode enabled)

3. Query Abstraction is Non-Negotiable

The Gotcha

Inline queries scattered everywhere:

<!-- ❌ Bad - Query in component -->
<script setup>
const { data } = await useSanityQuery(`
    *[_type == "post" && slug.current == $slug] {
        title,
        body,
        author->name
    }[0]
`, { slug });
</script>

Problems:

  • Hard to maintain
  • Duplicated queries
  • No type safety
  • Difficult to test
  • Can't reuse

The Solution

Create a queries directory:

// queries/posts/single.js
export const postSingleQuery = (isPreview = false) => /* groq */ `
    *[_type == "post" 
      ${isPreview ? '' : '&& !(_id in path("drafts.**"))'} 
      && slug.current == $slug
    ] {
        title,
        "author": author->name,
        body,
        publishedAt
    }[0]
`;

// In component
<script setup>
import { postSingleQuery } from '~/queries/posts/single';

const route = useRoute();
const isPreview = route.query.preview === 'true';
const { slug } = route.params;

const { data } = await useSanityQuery(postSingleQuery(isPreview), { slug });
</script>

Benefits:

  • ✅ Single source of truth
  • ✅ Easy to update across all pages
  • ✅ Testable in isolation
  • ✅ Supports preview mode
  • ✅ Better developer experience

4. Keep Your Data Clean

The Gotcha

Fetching everything "just in case":

// ❌ Bad - Fetching massive objects
*[_type == "post"] {
    ...,  // Everything!
    author->,  // Entire author document
    categories[]->,  // All category data
    relatedPosts[]->,  // Recursive madness
    _rawBody  // Raw portable text
}

Problems:

  • Massive payloads (slow page loads)
  • Expensive API usage
  • Client receives unused data
  • Slower time to interactive

The Solution

Fetch ONLY what you render:

// ✅ Good - Minimal, intentional
*[_type == "post"] {
    _id,
    title,
    excerpt,
    "slug": slug.current,
    "authorName": author->name,
    "categoryTitles": categories[]->title,
    publishedAt
}

Rule of thumb: If it's not in your template, it shouldn't be in your query.

Use Projections Wisely

// Get only the image URL, not the entire asset document
"imageUrl": image.asset->url

// Not:
// "image": image.asset->

5. Less is More: The Minimalist Approach

The Gotcha

Over-engineering Sanity schemas with fields you "might need later":

// ❌ Bad - "We might need this someday"
fields: [
    { name: 'title', type: 'string' },
    { name: 'subtitle', type: 'string' },
    { name: 'alternateTitle', type: 'string' },
    { name: 'seoTitle', type: 'string' },
    { name: 'socialTitle', type: 'string' },
    { name: 'internalTitle', type: 'string' },
    // ... 20 more title variations
]

Problems:

  • Confusing for content editors
  • Maintenance nightmare
  • Unused fields in 90% of documents
  • Slower queries

The Solution

Start minimal, add when needed:

// ✅ Good - One title field, multiple uses
fields: [
    {
        name: 'metaData',
        type: 'object',
        fields: [
            { 
                name: 'title', 
                type: 'string',
                description: 'Used for page title, SEO, and social sharing'
            },
            { name: 'description', type: 'text' }
        ]
    }
]

Philosophy: Add complexity only when the use case demands it. Your future self will thank you.

6. Branch Strategy & Staging Deployment

The Gotcha

Making major Sanity schema changes directly on main branch:

# ❌ Bad - Making breaking changes on main
git checkout main
# Edit schema...
git commit -m "changed everything"
git push origin main
# Production breaks 💥

Disaster scenarios:

  • Schema changes break production queries
  • Content editors can't publish
  • No easy rollback
  • Client sees broken site

The Solution

Always use feature branches + staging:

# ✅ Good workflow
git checkout -b feature/new-content-type
# Make schema changes
git commit -m "feat: add new content type"
git push origin feature/new-content-type

# Deploy to staging first
# AWS staging URL: staging.marcusjh.co.uk
# Test thoroughly
# Get client approval
# Then merge to main

Staging Checklist:

  • Schema changes tested in staging Sanity dataset
  • All queries returning expected data
  • Content editors can use new fields
  • Preview mode working
  • Client approves changes
  • No console errors

AWS Setup:

  • Main branch → Production
  • Develop branch → Staging
  • Feature branches → Preview deployments

7. Optional Chaining is Your Safety Net

The Gotcha

Trusting that data will always exist:

// ❌ Bad - Assumes perfect data
<h1>{{ post.author.name }}</h1>
<img :src="post.image.asset.url" />

What happens: Author deleted? Image missing? Site crashes with:

Cannot read properties of undefined (reading 'name')

The Solution

ALWAYS use optional chaining for nested data:

<template>
    <h1>{{ post?.author?.name || 'Anonymous' }}</h1>
    <img v-if="post?.image?.asset?.url" :src="post.image.asset.url" />
    <p>{{ post?.content?.paragraphs?.[0]?.text }}</p>
</template>

<script setup>
// In queries too
const authorName = data.value?.post?.author?.name ?? 'Unknown';
</script>

Sanity-specific gotcha:

// References can be null if document is deleted
"author": author->name  // Can be null!

// Always handle in template
{{ post?.author || 'No author' }}

8. Console Logs in Staging: Your Debug Lifeline

The Gotcha

Removing all console logs from production AND staging:

// ❌ Bad - No logs anywhere
if (process.env.NODE_ENV === 'production') {
    console.log = () => {};
}

Problem: When production data causes issues, you have no insight into what's happening.

The Solution

Keep logs in staging, remove only in production:

// nuxt.config.js
export default defineNuxtConfig({
    vite: {
        logLevel: process.env.STAGING ? 'info' : 'error'
    }
});

// In your code
const { data, error } = await useSanityQuery(query);

if (error.value) {
    console.error('Query error:', error.value);
}

// Staging-only detailed logging
if (process.env.STAGING) {
    console.log('Full response:', JSON.stringify(data.value, null, 2));
}

Environment setup:

# .env.production
NODE_ENV=production
STAGING=false

# .env.staging  
NODE_ENV=production
STAGING=true

What to log in staging:

  • Query responses
  • API errors
  • Data transformations
  • Sanity client requests
  • Preview mode status

9. Proper QA for Sanity Sites

The Gotcha

Thinking "it works locally" means it's ready:

# ❌ Insufficient testing
npm run dev  # Works!
git push  # Ship it! 💥

The Solution

Comprehensive QA checklist:

Content Level

  • Test with missing/null fields
  • Test with very long content
  • Test with empty arrays
  • Test with deleted references
  • Test with draft vs published documents
  • Test with special characters in text
  • Test with missing images

Data Scenarios

// Test all these states
const testScenarios = [
    { title: null, body: null },  // All null
    { title: '', body: '' },  // Empty strings
    { author: undefined },  // Missing reference
    { categories: [] },  // Empty array
    { image: { asset: null } },  // Null asset
    { body: Array(1000).fill('word') }  // Massive content
];

Technical Testing

  • Preview mode works for drafts
  • Published content appears correctly
  • Queries exclude drafts in production
  • CDN caching working (check response headers)
  • Images rendering at correct sizes
  • No hydration mismatches
  • Mobile responsive
  • Slow 3G network testing

QA Tools

# Build and test locally
npm run build
npm run preview

# Check for hydration errors
# Check Network tab for payload sizes
# Test with browser DevTools throttling

10. The srcset Width/Height Trap

The Gotcha

Setting arbitrary width/height without considering aspect ratio:

<!-- ❌ Bad - Wrong aspect ratio -->
<NuxtImg
    :src="image.url"
    width="800"
    height="400"
    alt="Hero"
/>
<!-- Original image is 1600x1200 (4:3 ratio)
     But we're forcing 800x400 (2:1 ratio)
     Result: Distorted or cropped image! -->

The disaster: Images look squished, stretched, or weirdly cropped.

The Solution

Calculate correct dimensions from aspect ratio:

// Option 1: Get dimensions from Sanity
const { data } = await useSanityQuery(/* groq */ `
    *[_type == "post"] {
        "image": {
            "url": image.asset->url,
            "width": image.asset->metadata.dimensions.width,
            "height": image.asset->metadata.dimensions.height,
            "aspectRatio": image.asset->metadata.dimensions.aspectRatio
        }
    }
`);

// Use actual dimensions
<NuxtImg
    :src="data.image.url"
    :width="data.image.width"
    :height="data.image.height"
    alt="Hero"
/>

// Or maintain aspect ratio when resizing
const targetWidth = 800;
const targetHeight = Math.round(targetWidth / data.image.aspectRatio);

Option 2: Let Sanity handle it:

// Use Sanity's image URL builder
import imageUrlBuilder from '@sanity/image-url';

const builder = imageUrlBuilder(sanityClient);

const getImageUrl = (source, width) => {
    return builder
        .image(source)
        .width(width)
        .auto('format')  // Automatic WebP/AVIF
        .fit('max')  // Maintains aspect ratio
        .url();
};

Option 3: Use CSS aspect ratio:

<template>
    <div class="image-container">
        <NuxtImg
            :src="image.url"
            sizes="sm:100vw md:50vw lg:800px"
            alt="Hero"
        />
    </div>
</template>

<style>
.image-container {
    aspect-ratio: 16 / 9;
    overflow: hidden;
}

.image-container img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}
</style>

Pro tip: Always check the original image dimensions in Sanity Studio before setting width/height!

Bonus Gotchas

11. The Reference Deletion Problem

// ❌ Bad - No null check
"author": author->name

// In template
<p>By {{ post.author }}</p>  // Crashes if author is deleted!

// ✅ Good - Handle deleted references
"author": coalesce(author->name, "Unknown Author")

// Or in template
<p>By {{ post?.author || 'Unknown' }}</p>

12. The Array Filter Trap

// ❌ Bad - Assumes array exists
export const portableTextToPlain = (blocks = []) => {
    return blocks.filter(block => block._type === 'block');
    // Crashes if blocks is explicitly null!
};

// ✅ Good - Defensive programming
export const portableTextToPlain = (blocks = []) => {
    return (blocks || []).filter(block => block._type === 'block');
};

13. Draft vs Published Confusion

// ❌ Bad - Shows drafts in production
*[_type == "post"]

// ✅ Good - Filter drafts
*[_type == "post" && !(_id in path("drafts.**"))]

// Even better - Make it conditional
export const postsQuery = (isPreview = false) => /* groq */ `
    *[_type == "post" 
      ${isPreview ? '' : '&& !(_id in path("drafts.**"))'} 
    ]
`;

14. The Portable Text Rendering Gotcha

<!-- ❌ Bad - No validation -->
<PortableText :value="content" />
<!-- Crashes if content is null or not an array -->

<!-- ✅ Good - Validate first -->
<PortableText 
    v-if="content && Array.isArray(content)" 
    :value="content" 
/>
<p v-else>No content available</p>

15. Slug Configuration Mistakes

// ❌ Bad - Missing fullUrl for prefixed slugs
{
    name: 'slug',
    type: 'slug',
    options: {
        source: 'title',
        urlPrefix: '/blog'
        // Missing: storeFullUrl: true
    }
}

// Query breaks because slug.fullUrl doesn't exist
*[slug.fullUrl == $slug]  // undefined!

// ✅ Good - Store full URL
{
    name: 'slug',
    type: 'slug',
    options: {
        source: 'title',
        urlPrefix: '/blog',
        storeFullUrl: true  // Critical!
    }
}

Best Practices Checklist

Schema Design

  • Use metaData object for titles/descriptions (one source)
  • Make fields optional unless truly required
  • Add helpful descriptions for editors
  • Use validation rules sparingly
  • Test schema with missing data

Query Design

  • Abstract all queries to separate files
  • Support preview mode parameter
  • Use projections (only fetch what you need)
  • Handle null references with coalesce()
  • Filter out drafts in production

Frontend

  • Optional chaining for ALL nested data (?.)
  • Validate arrays before mapping
  • Check for null before rendering components
  • Use v-if for conditional content
  • Provide fallback content

Development Workflow

  • Feature branches for schema changes
  • Test in staging with preview mode
  • QA with production data in staging
  • Keep console logs in staging
  • Client approval before production deploy

Performance

  • useCdn: true (except preview)
  • Implement proper caching (SWR, ISR)
  • Optimize image dimensions
  • Lazy load heavy content
  • Monitor API usage in Sanity dashboard

Real-World Example: The Complete Pattern

Here's how all these gotchas come together in a production-ready setup:

// queries/posts/single.js
export const postSingleQuery = (isPreview = false) => /* groq */ `
    *[_type == "post" 
      ${isPreview ? '' : '&& !(_id in path("drafts.**"))'} 
      && slug.current == $slug
    ] {
        "title": metaData.title,
        "description": metaData.description,
        "imageUrl": image.asset->url,
        "imageWidth": image.asset->metadata.dimensions.width,
        "imageHeight": image.asset->metadata.dimensions.height,
        "authorName": coalesce(author->name, "Anonymous"),
        "categoryNames": categories[]->title,
        body
    }[0]
`;

// pages/blog/[slug].vue
<script setup>
const route = useRoute();
const { slug } = route.params;
const isPreview = route.query.preview === 'true';

const { data, error } = await useSanityQuery(
    postSingleQuery(isPreview), 
    { slug }
);

if (error.value) {
    console.error('Post query error:', error.value);
}

// Staging debug logs
if (process.env.STAGING) {
    console.log('Post data:', data.value);
}
</script>

<template>
    <article v-if="data">
        <h1>{{ data?.title || 'Untitled' }}</h1>
        
        <NuxtImg
            v-if="data?.imageUrl"
            :src="data.imageUrl"
            :width="data?.imageWidth"
            :height="data?.imageHeight"
            loading="lazy"
            alt=""
        />
        
        <p v-if="data?.authorName">By {{ data.authorName }}</p>
        
        <ul v-if="data?.categoryNames?.length">
            <li v-for="cat in data.categoryNames" :key="cat">
                {{ cat }}
            </li>
        </ul>
        
        <PortableText 
            v-if="data?.body && Array.isArray(data.body)"
            :value="data.body" 
        />
    </article>
    
    <div v-else>
        <p>Post not found</p>
    </div>
</template>

When Things Go Wrong: Debugging Checklist

Production is broken?

  1. Check Sanity Vision - Run your query directly
  2. Verify CDN - Check if useCdn is correct
  3. Check drafts - Are drafts leaking to production?
  4. Inspect payload - Use Network tab, look for null values
  5. Check console - Any errors in browser console?
  6. Verify references - Have any referenced documents been deleted?
  7. Test in staging - Can you reproduce there?

Preview not working?

  1. Staging environment? Preview should go through staging
  2. CDN disabled? Preview needs useCdn: false
  3. Query includes drafts? Check the isPreview parameter
  4. Correct URL? Should have ?preview=true
  5. Document saved? Draft must be saved (not just edited)

Images broken?

  1. Check aspect ratio - Does width/height match original?
  2. Verify asset exists - Asset might be deleted
  3. Optional chaining? Check for image?.asset?.url
  4. Query projection? Make sure you're fetching the URL
  5. CORS issues? Check Sanity CORS settings

Production Horror Stories (Learn From My Mistakes)

The Midnight Deploy

Changed schema on main branch Friday evening. Deployed. Production queries broke because I forgot to update the query projections. Spent weekend rolling back and fixing.

Lesson: Feature branches + staging + Monday deploys only.

The Missing Author Crisis

Client deleted an author document. Every post by that author crashed because I used author.name without optional chaining.

Lesson: Optional chaining. Everywhere. Always.

The CDN Cache Mystery

Changed content in Sanity, refreshed site, saw old content. Spent 2 hours debugging before realizing CDN takes ~60 seconds to update.

Lesson: CDN is not instant. Wait a minute. Literally.

The Preview Leak

Had preview mode on production with useCdn: false. Draft content briefly visible to users. Client panicked.

Lesson: Preview only on staging. Never production.

The Golden Rules

  1. CDN on, except for preview (and preview goes through staging)
  2. Abstract your queries (DRY principle saves lives)
  3. Less is more (resist the urge to add fields "just in case")
  4. Optional chain everything (trust nothing)
  5. Branch and stage (main branch is sacred)
  6. Log in staging (silence in production)
  7. QA rigorously (test edge cases)
  8. Respect aspect ratios (your designers will thank you)

Tools That Save Time

  • Sanity Vision - Test queries in real-time
  • Sanity Studio Desk Tool - Customize editor layout
  • Sanity Image URL Builder - Handle image transformations
  • Browser DevTools Network Tab - Inspect payloads
  • Staging Environment - Catch issues before production