Sanity Preview Not Working: Causes and Fixes for Next.js
By Nayan Kyada · · 8 min read
Part of The Sanity + Next.js Guide
Your editor clicks Presentation in the Studio and gets a blank frame, a refused connection, or a page that loads but shows published content and no edit overlays. Preview failures almost always come down to five things: framing and CORS, the enable route and secret, the draftMode cookie, the fetch not using drafts, and caching. Work through them in this order, because each one hides the next.
This post assumes you already have Presentation wired up. If you do not, start with Sanity Live Preview in Next.js App Router (Presentation). This is only about diagnosis.
Symptom checklist: which failure do you have?
Match what you see before changing code:
| What you see | Most likely cause |
|---|---|
| Blank frame, console says refused to display in a frame | Frame headers (cause 1) |
| Frame loads, Studio says it cannot connect or stays on loading | Missing visual editing component or origin mismatch (cause 2) |
| Enable route returns 401 or 400 | Secret or token mismatch (cause 3) |
| Page loads, but shows published content | Fetch ignoring drafts (cause 4) |
| Works once, then goes stale | Caching (cause 5) |
| Content shows but no click-to-edit overlays | Stega or missing component (cause 6) |
Cause 1: the page cannot be framed
Presentation renders your site inside an iframe in the Studio. If your app sends a header that forbids framing, the browser blocks it, and you get a blank pane. This is common on sites where someone added security headers to pass an audit.
The broken pattern:
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
},
]
},
}
export default nextConfigThe fix is to allow your Studio origin with a Content-Security-Policy frame-ancestors directive instead, and drop the blanket DENY. Put your real Studio URL in the list, plus the local one for development:
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value:
"frame-ancestors 'self' https://studio.example.com http://localhost:3333",
},
],
},
]
},
}
export default nextConfigIf you want to be stricter, apply the permissive policy only on preview deployments using an environment variable. Do not leave production open to every origin.
Cause 2: origin and URL mismatches
Two separate origin settings trip people up.
- The previewUrl in the Presentation tool. If it points at localhost in production Studio, or at a deployment URL that has since changed, the frame loads the wrong thing or nothing. Check the
previewUrl.originvalue in your Studio config for the environment you are actually using. - CORS origins in the Sanity project. Client-side queries from the frame, such as live updates, need your site origin allowed in the project's API settings in the Sanity management console. Add the exact origin, including protocol and port, and enable credentials only for origins you trust.
A mismatch between a www and non-www domain, or between a Vercel preview URL and your production domain, is the classic one. The browser console will show a CORS error naming the origin it rejected. Copy that string exactly.
Cause 3: the enable route and secret
Presentation hits your draft-enable route with a secret it generated. If your route rejects it, the frame never enters draft mode. Typical reasons:
- The route handler path in your code does not match the
previewMode.enablepath in the Studio config. - The read token is missing in the environment where the route runs. Vercel has separate variables per environment, so a token set only for Production fails on Preview.
- The token has viewer access only, or was created for another project. It needs read access to the dataset including drafts.
- You are validating against a hand-rolled secret instead of letting the Sanity helper validate the request.
The working route, using the next-sanity helper:
// src/app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
import { client } from '@/sanity/lib/client'
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
})If this returns 401, log whether process.env.SANITY_API_READ_TOKEN is defined on that deployment. It is undefined far more often than the token is wrong.
Cause 4: the draftMode cookie is set but the fetch ignores it
Draft mode only sets a cookie. It does not change what your queries return. If the page shows published content while the toolbar says draft mode is on, your fetch layer is not switching.
In Next.js 16, draftMode() is async. Forgetting to await it is a common bug when code is copied from older examples. The reliable pattern is defineLive from next-sanity, which handles perspective and token for you. If you use a plain client, switch the perspective yourself:
// src/sanity/lib/fetch.ts
import { draftMode } from 'next/headers'
import { client } from './client'
export async function sanityFetch<T>(query: string, params = {}) {
const { isEnabled } = await draftMode()
return client
.withConfig({
useCdn: !isEnabled,
token: isEnabled ? process.env.SANITY_API_READ_TOKEN : undefined,
perspective: isEnabled ? 'drafts' : 'published',
})
.fetch<T>(query, params)
}Two details matter here. The CDN must be off for drafts, because the CDN only serves published content. And the token must stay on the server. If a token ever reaches the browser bundle, rotate it.
Also check third-party cookies. The Studio and your site are on different origins, so the draftMode cookie is set in an iframe context. Browsers that block third-party cookies can drop it. Sanity's helper sets the cookie in a way designed for this, but a hand-written route that omits the right attributes will fail in Safari and in some Chrome configurations. Test in more than one browser before blaming your code.
Cause 5: caching serves stale or published output
Pages that are statically cached will not reflect drafts. Draft mode opts the request out of the static cache, but your own cache layers do not know that. Look for:
- A
use cachefunction or tagged fetch wrapping your data call without keying on draft state. The published result gets served in draft mode. - A CDN or proxy in front of the preview deployment caching responses that carry the draft cookie.
- Middleware that rewrites or redirects before the cookie is read.
The rule: when draft mode is on, bypass your cache wrapper entirely. Keep the cached path for published traffic and call the uncached path for drafts. If you run an edge setup, Sanity Draft Mode Without Sacrificing Edge Performance covers how to keep that split without slowing visitors down.
Cause 6: stega and missing overlays
If content updates but click-to-edit overlays are missing, stega is not active or the visual editing component is not mounted. Stega embeds invisible source data into strings so the overlay knows which field each piece of text came from.
// src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2025-01-01',
useCdn: true,
stega: { studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL },
})Then render the visual editing component in your root layout only when draft mode is enabled. Without it, the frame loads but nothing is clickable.
The flip side: stega characters break strings you compare or pass to non-display code, such as slugs used in URLs, enum values, or JSON-LD. Strip stega for those fields, or apply stega only on the draft-mode client. If a slug lookup fails only in preview, this is the reason.
How to verify the fix
- Open the enable route directly with the secret from the Presentation request in the network tab and confirm it returns a redirect, not a 401.
- Check the response sets a draft-mode cookie, then reload a page and confirm the toolbar or your own indicator shows draft state.
- Edit a field in the Studio without publishing. The frame should update without a manual refresh.
- Click a text element and confirm the Studio jumps to that field.
- Repeat in a second browser and on the deployed Preview URL, not just localhost.
- Open the site in a private window with no cookie and confirm visitors still see only published content.
If you still cannot find it after these, that is the point where an outside look is cheaper than another evening of guessing. A Performance & Sanity audit, from $800, covers the preview setup alongside everything else on the site.
Frequently asked questions
01Why is my Sanity Presentation preview blank or showing a refused to connect message?
Usually the page is blocked from being framed, or the Studio origin is not allowed. Check that your Next.js app does not send an X-Frame-Options DENY header or a frame-ancestors policy that excludes your Studio origin, and that the previewUrl in the Presentation tool points at the right deployment.
02Why does draft mode enable but I still see published content?
Your data fetch is almost certainly not switching to the drafts perspective or using a token. Draft mode only sets a cookie. The query still has to run with a read token and the drafts perspective (previewDrafts in older client versions) when draftMode is enabled.
03Why do click-to-edit overlays not appear in Presentation?
Stega encoding is probably off, or you are not rendering the visual editing component in draft mode. Check that the client uses a stega option with studioUrl, and that the visual editing component is mounted only when draftMode is enabled.