Fix: Sanity Not Working — GROQ Queries Returning Nothing, CORS Errors, or Studio Not Loading
Part of: React & Frontend Errors
Quick Answer
How to fix Sanity CMS issues — GROQ queries, CORS configuration, dataset permissions, image URLs, Portable Text, webhooks, and Next.js integration.
The Problem
A GROQ query returns an empty array even though documents exist in the studio:
const posts = await client.fetch(`*[_type == "post"]`);
// [] — empty, despite content existing in Sanity StudioOr the browser throws a CORS error:
Access to fetch at 'https://yourproject.api.sanity.io/v2021-10-21/data/query/production'
from origin 'http://localhost:3000' has been blocked by CORS policyOr Sanity Studio fails to load with a blank screen:
Error: Sanity Studio failed to load. Check the console for details.Why This Happens
Sanity separates the dataset (where content lives) from the API (how you query it). Several mismatches can cause empty results:
- Dataset name mismatch — Sanity projects often have
productionanddevelopmentdatasets. Querying the wrong dataset returns nothing. - CORS origins not configured — Sanity’s API blocks browser requests from origins not explicitly allowlisted in the project settings.
- Draft documents vs. published — Drafts have IDs prefixed with
drafts.. A GROQ query for*[_type == "post"]returns published documents only unless you filter for drafts explicitly. - API version mismatch — Sanity’s API is versioned by date. Using a version older than when a feature was released can cause unexpected behavior.
The dataset model is what catches most developers off guard. Sanity treats each dataset as a fully independent database — schemas can drift between production and development, and a document with the same _id can exist in one and not the other. If you copy .env.example into .env.local and forget to switch NEXT_PUBLIC_SANITY_DATASET, every query runs against the wrong dataset and returns nothing. There is no warning because the request itself succeeds (HTTP 200) — only the result is empty. Always log the resolved dataset in your client setup at least once in dev, and verify it matches what you see in the studio’s URL bar.
The CDN behavior is the other quiet trap. useCdn: true routes reads through Sanity’s globally cached edge, which is fast but eventually consistent. Right after a publish, the CDN may still serve the old version for up to 60 seconds. For static generation this is fine; for previews, webhooks, or anywhere that triggers a query immediately after a mutation, you must use useCdn: false or include a revalidateTag flow. The wrong choice doesn’t produce an error — it produces stale data that magically updates a minute later, which makes the bug hard to reproduce.
API versioning compounds these issues. Sanity pins every query to a date string. If a GROQ feature (like the score() operator) was added on 2023-05-01 and your client says apiVersion: '2021-10-21', the parser silently treats the new syntax as a name lookup and returns null. Always use a recent ISO date.
Fix 1: Client Configuration
// sanity/lib/client.ts
import { createClient } from 'next-sanity';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, // e.g. 'abc12def'
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!, // 'production'
apiVersion: '2024-01-01', // Use today's date or later — do NOT use a past date
useCdn: true, // true = cached CDN reads (faster, may be stale by ~60s)
// false = always fresh data (required for previews/ISR)
token: process.env.SANITY_API_TOKEN, // Only needed for write operations or private datasets
});// .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=abc12def
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=skAbcDefGhi... # Read/write token from Manage → API → TokensCommon Mistake: Using useCdn: true in a preview or webhook handler means you’ll always get stale data. Use useCdn: false anywhere you need fresh data immediately after a publish.
Fix 2: CORS Configuration
Go to sanity.io/manage → your project → API → CORS Origins → Add CORS Origin.
# Add these origins:
http://localhost:3000 ← local development
https://your-domain.com ← production
https://your-preview.vercel.app ← preview deployments
# If using Vercel with branch deploys, add a wildcard:
https://*.vercel.appCheck Allow credentials if you’re sending authentication cookies.
For programmatic setup via the Sanity CLI:
# List current CORS origins
npx sanity cors list
# Add an origin
npx sanity cors add https://your-domain.com --credentialsNote: CORS only affects browser requests. Server-side fetches (Next.js getServerSideProps, route handlers, generateStaticParams) are never blocked by CORS — only frontend JavaScript is.
How Other Tools Handle This
The headless CMS market has converged on a small number of architectures, but the operational differences are large enough to change how you debug missing data.
Contentful is the closest SaaS competitor. Like Sanity, content lives in a hosted dataset and is served through a global CDN. The query language is REST-only (/spaces/<id>/entries?content_type=post) with simple filters, and the dataset model is replaced by environments (master, staging). CORS is managed per-API-key rather than per-project, and previews use a separate Preview API with its own token. Where Sanity uses GROQ for flexible projections, Contentful relies on the include parameter to flatten references up to ten levels deep — less expressive but easier to teach.
Strapi is the self-hosted opposite. You run the server yourself, content lives in your own database, and permissions are role-based rather than token-based. The trade-off: every Sanity feature (CDN, image transformation, real-time updates) has to be reproduced manually. Sanity charges per API request and storage; Strapi charges you the SRE bill. See Fix: Strapi Not Working for the permission-driven 403 pattern that has no Sanity equivalent.
Payload CMS is code-first like Strapi but uses TypeScript collections and ships a Next.js admin. It’s the natural choice for teams that want to colocate their CMS with their app code. Payload doesn’t have GROQ — it uses MongoDB-style query objects. Where Sanity’s strength is decoupling and CDN, Payload’s strength is end-to-end TypeScript. See Fix: Payload CMS Not Working for the code-first alternative.
Storyblok sits between Sanity and Contentful philosophically. It pairs a visual editor with a JSON-based API, and the content model is “components in stories” rather than typed documents. CORS is open by default, and there’s no GROQ — just a query string filter language. Storyblok’s real-time visual preview is the strongest in the market, but the JSON shape makes complex queries harder than GROQ.
WordPress headless uses the WP REST API or WPGraphQL on top of an existing WordPress install. It’s the cheapest option if you already host WordPress, but performance under load is bad without aggressive caching, and the data shape is verbose. There’s no draft-API distinction (drafts require an authenticated request), and no global CDN unless you put Cloudflare in front yourself.
CDN delivery and GROQ vs GraphQL is the practical dividing line. Sanity’s CDN auto-invalidates on mutation and serves at edge speed, which is hard to beat for cached reads. GROQ projections let you shape responses on the server, removing the need for the client to know about graph traversal. GraphQL gives you tooling (codegen, persisted queries) that GROQ doesn’t, and Sanity exposes a GraphQL endpoint as well — but the GraphQL schema is less expressive than GROQ for nested array projections. See Fix: Directus Not Working for a self-hosted CMS that pairs SQL queries with REST and GraphQL out of the box.
Fix 3: GROQ Queries
// Basic queries
*[_type == "post"] // All posts
*[_type == "post"][0] // First post
*[_type == "post"] | order(publishedAt desc) // Sorted
*[_type == "post"][0..9] // First 10 (slice)
*[_type == "post"] | order(publishedAt desc)[0..4] // Top 5 recent
// Filtering
*[_type == "post" && defined(publishedAt)] // Only published posts
*[_type == "post" && slug.current == $slug] // By slug (use parameter)
*[_type == "post" && category._ref == $categoryId]
// Projection — select only what you need
*[_type == "post"] {
_id,
title,
"slug": slug.current, // Rename/unwrap fields
publishedAt,
"author": author->{ name, image }, // Dereference relation with ->
"categories": categories[]->{ title, slug }, // Array of relations
}
// Nested projection
*[_type == "post"] {
title,
body[]{ // Portable Text blocks
...,
_type == "image" => {
...,
asset->{ url, metadata }
}
}
}
// Count
count(*[_type == "post"])
// Conditional fields
*[_type == "post"] {
title,
"isNew": dateTime(publishedAt) > dateTime(now()) - 60*60*24*7
}// TypeScript — use parameters to prevent injection
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug: 'my-post-slug' } // Always pass dynamic values as params
);Fix 4: Drafts and Live Preview
// Drafts have IDs prefixed with "drafts."
// This query returns BOTH published and draft versions:
*[_type == "post" && !(_id in path("drafts.**"))] // Published only (default)
*[_type == "post" && _id in path("drafts.**")] // Drafts only
*[_type == "post"] // Both (may show duplicates)// Live preview with next-sanity (App Router)
// app/api/draft/route.ts — enable draft mode
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(`/blog/${slug}`);
}
// In your page component
import { draftMode } from 'next/headers';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const { isEnabled: preview } = draftMode();
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug: params.slug },
{ next: { tags: ['post'] }, useCdn: !preview }
);
return <PostContent post={post} />;
}Fix 5: Image URLs
// Install the URL builder
// npm install @sanity/image-url
import imageUrlBuilder from '@sanity/image-url';
import { client } from './client';
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
return builder.image(source);
}
// Usage
const imageUrl = urlFor(post.mainImage)
.width(800)
.height(600)
.format('webp')
.quality(80)
.url();
// In Next.js Image component
import Image from 'next/image';
<Image
src={urlFor(post.mainImage).width(1200).url()}
alt={post.mainImage.alt ?? post.title}
width={1200}
height={630}
/>// next.config.js — allow Sanity CDN images
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
},
],
},
};Pro Tip: Always include alt text on Sanity image fields. Set a required alt validation in the schema:
// schemas/post.ts
defineField({
name: 'mainImage',
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
type: 'string',
title: 'Alternative text',
validation: (rule) => rule.required(),
}),
],
}),Fix 6: Portable Text Rendering
npm install @portabletext/react// components/PortableText.tsx
import { PortableText } from '@portabletext/react';
import imageUrlBuilder from '@sanity/image-url';
import { client } from '@/sanity/lib/client';
import Image from 'next/image';
const builder = imageUrlBuilder(client);
const components = {
types: {
image: ({ value }: any) => {
const src = builder.image(value).width(800).url();
return (
<div className="my-8">
<Image
src={src}
alt={value.alt ?? ''}
width={800}
height={500}
className="rounded-lg"
/>
{value.caption && (
<p className="text-sm text-gray-500 mt-2">{value.caption}</p>
)}
</div>
);
},
callout: ({ value }: any) => (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
<p>{value.text}</p>
</div>
),
},
marks: {
link: ({ children, value }: any) => (
<a href={value.href} target="_blank" rel="noopener noreferrer" className="text-blue-600 underline">
{children}
</a>
),
code: ({ children }: any) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
block: {
h2: ({ children }: any) => <h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xl font-bold mt-6 mb-3">{children}</h3>,
blockquote: ({ children }: any) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">
{children}
</blockquote>
),
},
};
export function RichText({ value }: { value: any[] }) {
return <PortableText value={value} components={components} />;
}Fix 7: Webhooks and On-Demand Revalidation
// app/api/revalidate/route.ts — Next.js on-demand ISR via Sanity webhook
import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';
export async function POST(req: NextRequest) {
try {
const { body, isValidSignature } = await parseBody<{ _type: string }>(
req,
process.env.SANITY_WEBHOOK_SECRET
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
if (!body?._type) {
return new Response('Bad request', { status: 400 });
}
// Revalidate tag matching the content type
revalidateTag(body._type);
return NextResponse.json({ revalidated: true, type: body._type });
} catch (err) {
console.error(err);
return new Response('Error', { status: 500 });
}
}// Fetch with cache tags so revalidation works
const posts = await client.fetch(
`*[_type == "post"] | order(publishedAt desc)`,
{},
{ next: { tags: ['post'] } } // Tag matches what the webhook revalidates
);In Sanity Manage, add a webhook:
- URL:
https://your-domain.com/api/revalidate - Trigger on: Create, Update, Delete
- Add secret header:
SANITY_WEBHOOK_SECRETvalue
Still Not Working?
Empty query results — verify the dataset in sanity.config.ts and your .env match. Open Sanity Studio → Content to confirm documents exist in that dataset. Also check that documents are Published (not just saved as drafts).
CORS error in browser — the request origin is not in the allowed list. Add it in Manage → API → CORS Origins. Remember that http://localhost:3000 and http://localhost:3001 are treated as different origins.
undefined on image URL — the image field in your schema is not populated in the GROQ query. Add asset-> to the projection: "image": mainImage { ..., asset->{ url } }.
Portable Text shows [object Object] — you’re rendering raw Portable Text without the @portabletext/react renderer. Never do {post.body} directly — pass it to <PortableText value={post.body} />.
Studio blank screen — check the browser console. Common causes: projectId not set, CORS blocking the Sanity API, or a schema file with a syntax error. Run npx sanity check to validate the schema.
Webhook signature validation fails on Vercel — Vercel’s edge function runtime sometimes mutates the request body before it reaches parseBody(). Use the Node runtime explicitly (export const runtime = 'nodejs') so the raw body is preserved for HMAC verification. The Sanity webhook signature is computed over the byte-exact payload, so any normalization breaks it.
Recently published content does not appear in static pages — Next.js cached the build. With useCdn: true and ISR, you also need to call revalidateTag (App Router) or revalidatePath (Pages Router) from a Sanity webhook. Without revalidation, the CDN may be fresh but Next.js still serves the old HTML for up to the configured revalidation window.
Listening API drops connections in production — client.listen() keeps a long-lived EventSource open. Proxies and load balancers often close idle connections after 30-60 seconds. Wrap the subscription in a reconnect helper, or use Sanity’s experimental_listen() which handles reconnection automatically.
For related CMS issues, see Fix: Strapi Not Working and Fix: Contentlayer Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Algolia Not Working — Search Returning No Results, Index Not Updating, or InstantSearch Errors
How to fix Algolia issues — indexing, search queries, InstantSearch setup, ranking configuration, facets, API key permissions, and Next.js integration.
Fix: Directus Not Working — API Returning 403, Items Not Appearing, or Flows Not Triggering
How to fix Directus issues — permissions, access policies, collections, REST and GraphQL APIs, file uploads, Flows automation, and self-hosted deployment.
Fix: Chakra UI Not Working — Provider Missing, Styles Not Applied, or Dark Mode Broken
How to fix Chakra UI v3 issues — ChakraProvider setup, color mode, theming, server-side rendering in Next.js, component styling, and common runtime errors.
Fix: Strapi Not Working — API Returns 403, Content Not Appearing, or Plugin Errors
How to fix Strapi v5 issues — permissions, content types, REST and GraphQL APIs, media uploads, webhooks, plugins, and deployment configuration.