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:
- Production →
useCdn: true
, published content only - Staging →
useCdn: false
, preview mode enabled - 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?
- Check Sanity Vision - Run your query directly
- Verify CDN - Check if
useCdn
is correct - Check drafts - Are drafts leaking to production?
- Inspect payload - Use Network tab, look for null values
- Check console - Any errors in browser console?
- Verify references - Have any referenced documents been deleted?
- Test in staging - Can you reproduce there?
Preview not working?
- Staging environment? Preview should go through staging
- CDN disabled? Preview needs
useCdn: false
- Query includes drafts? Check the
isPreview
parameter - Correct URL? Should have
?preview=true
- Document saved? Draft must be saved (not just edited)
Images broken?
- Check aspect ratio - Does width/height match original?
- Verify asset exists - Asset might be deleted
- Optional chaining? Check for
image?.asset?.url
- Query projection? Make sure you're fetching the URL
- 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
- CDN on, except for preview (and preview goes through staging)
- Abstract your queries (DRY principle saves lives)
- Less is more (resist the urge to add fields "just in case")
- Optional chain everything (trust nothing)
- Branch and stage (main branch is sacred)
- Log in staging (silence in production)
- QA rigorously (test edge cases)
- 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