The Next.js 16 upgrade looks healthy in local development. Pages render, Draft Mode works, and published Sanity changes still appear. Then production traffic settles in and two graphs move in the wrong direction: Sanity API requests climb without a matching traffic increase, and Vercel records far more ISR writes on content routes.
This failure is easy to misdiagnose as a missing cache. In the affected version combination, the problem is almost the opposite: one content event invalidates client state, visible links prefetch again, those route requests touch Sanity-tagged data, and the newly revalidated output produces more cache work. A single publish can become a request cascade.
The specific high-risk combination is Next.js 16, the App Router, next-sanity v12, defineLive, and a rendered SanityLive component. Do not apply a cache workaround to a different stack merely because the symptoms look similar.
The observable failure pattern
A useful diagnosis starts with correlated evidence, not a single dashboard. Compare the deployment timestamp with Sanity request volume, Vercel ISR writes, route traffic, and browser network activity. If traffic stayed flat while the other counters jumped immediately after the framework upgrade, the version transition is the leading suspect.
[ ] Next.js is version 16.x
[ ] next-sanity is version 12.x
[ ] The app uses defineLive()
[ ] <SanityLive /> renders outside Draft Mode
[ ] Sanity API requests rose without equivalent traffic growth
[ ] Vercel ISR writes rose on Sanity-backed routes
[ ] One visible <Link> produces several ?rsc requests
[ ] Publishing one document causes another burstSanity has reported roughly four times the request load in typical affected production apps, with heavier marketing and documentation sites reaching seven to ten times their previous load. Treat those values as a diagnostic range, not a threshold. Your route count, link density, query layout, and cache topology determine the shape of the spike.
Why one live event becomes many requests
Next.js 16 changed link prefetch behavior. In next-sanity v12, SanityLive handled a sync-tag event by expiring changed tags immediately. That invalidation could clear client router state. Visible links then prefetched again, and every affected route could fetch Sanity data and write a new ISR result.
Editor publishes one document
-> Sanity emits sync tags
-> SanityLive expires tags immediately
-> Next.js client cache is cleared
-> visible links prefetch again
-> several ?rsc route requests run
-> tagged Sanity queries execute
-> affected routes produce ISR writes
-> the next live event repeats the cycleThis explains why adding a longer time-based cache can fail to solve the problem. The expensive work is driven by on-demand invalidation and client prefetch behavior. The fix must change the invalidation path, not simply extend a timer.
Preferred fix: upgrade to next-sanity v13
next-sanity v13 changes the default SanityLive invalidation behavior and supports Next.js Cache Components. It is a breaking upgrade, so treat it as a migration rather than a dependency bump. Record the current request baseline, upgrade in a branch, and test published and draft rendering separately.
pnpm add --save-exact next-sanity@^13
pnpm list next next-sanity @sanity/clientThe SanityLive API changed. The old revalidateSyncTags prop becomes action, and older focus or reconnect behavior may need an explicit replacement if your product depended on it.
Configure Cache Components for Sanity data
With Cache Components enabled, cached Sanity data should use a long cache life and depend on precise on-demand sync tags for freshness. The Sanity cache-life preset exists for that model. It avoids refreshing stable content every fifteen minutes simply because the default profile says so.
import type {NextConfig} from 'next'
import {sanity} from 'next-sanity/live/cache-life'
const config = {
cacheComponents: true,
cacheLife: {
default: sanity,
},
} satisfies NextConfig
export default configThis setting does not make every function cacheable. It establishes the cache profile. Your Sanity fetch still needs to run inside a use-cache boundary, while request-time APIs such as draftMode and cookies must remain outside that boundary.
Create one strict live integration
Centralize defineLive instead of configuring live behavior in individual routes. A strict integration forces every fetch to state whether it wants published or draft content and whether stega encoding should be active. That makes accidental draft leakage and cache ambiguity harder.
import {defineLive} from 'next-sanity/live'
import {client} from './client'
const readToken = process.env.SANITY_API_READ_TOKEN
if (!readToken) throw new Error('Missing SANITY_API_READ_TOKEN')
export const {sanityFetch, SanityLive} = defineLive({
client,
serverToken: readToken,
browserToken: readToken,
strict: true,
})
export async function cachedSanity(options) {
'use cache'
return sanityFetch(options)
}The live preview browser token is intentionally exposed to the browser during draft sessions. Give it only the minimum read permission required for previews.
Keep request-time state outside the cache boundary
Cache Components make the boundary explicit. draftMode, cookies, headers, and route params can influence the requested perspective, but those runtime values cannot be read inside a shared use-cache function. Resolve them first, reduce them to serializable options, and pass those options into the cached layer.
export default function ArticlePage({params}) {
return <ArticleRequest params={params} />
}
async function ArticleRequest({params}) {
const {slug} = await params
const liveOptions = await getDynamicFetchOptions()
return <CachedArticle slug={slug} liveOptions={liveOptions} />
}
async function CachedArticle({slug, liveOptions}) {
'use cache'
const {data} = await sanityFetch({
query: ARTICLE_QUERY,
params: {slug},
...liveOptions,
})
return <ArticleView article={data} />
}The first layer defines the route. The second resolves request-time state. The third performs the cached Sanity query. This separation looks more deliberate than older App Router examples because it is expressing two different lifetimes: per-request preview state and reusable published content.
Render SanityLive once
Render one SanityLive component in the website layout and pass the draft state explicitly. Visual Editing should render only for draft sessions. If the Sanity Studio is embedded under the same Next.js root, put the live component in a website route-group layout so Studio routes do not inherit it.
import {draftMode} from 'next/headers'
import {VisualEditing} from 'next-sanity/visual-editing'
import {SanityLive} from '@/sanity/lib/live'
export default async function WebsiteLayout({children}) {
const {isEnabled} = await draftMode()
return (
<>
{children}
<SanityLive includeDrafts={isEnabled} />
{isEnabled ? <VisualEditing /> : null}
</>
)
}If you cannot leave next-sanity v12 yet
A staged migration may need a containment step. On v12, keep sanityFetch for published data, but render SanityLive only when Draft Mode or Presentation Tool is active. In that draft-only path, replace broad immediate tag expiration with one controlled refresh action.
'use client'
export async function refreshAction(): Promise<'refresh'> {
return 'refresh'
}{isDraftMode ? (
<SanityLive revalidateSyncTags={refreshAction} />
) : null}Sanity also recommends using a sync-tag invalidation function for published content and calling revalidateTag with the max profile in the receiving route. That path keeps published updates on precise tags without placing the live browser component on every production page.
The v12 configuration reduces the immediate production risk, but it does not remove the need to plan the v13 migration. Document the temporary path and give it an owner and removal date.
Verify the fix in production-shaped conditions
A successful build proves very little about this problem. The cascade depends on navigation, visible links, live events, and production caching. Test on a preview deployment with representative link density and at least one Sanity-backed static route.
1. Record Sanity request and Vercel ISR baselines
2. Open a page with several visible internal links
3. Capture ?rsc requests before publishing
4. Publish one document used by exactly one route
5. Confirm only the relevant route refreshes
6. Navigate to a second cached route
7. Verify published content has no stega markers
8. Enable Draft Mode and confirm previews update
9. Disable Draft Mode and confirm the published perspective
10. Watch request and ISR graphs through a normal traffic windowWhat good looks like
Request volume follows actual traffic instead of publish events. A document update revalidates the routes that consume its sync tags. Navigation does not trigger a growing wave of repeated RSC fetches. Draft Mode remains live, while ordinary visitors receive clean published content without preview metadata.
Common migration mistakes
Upgrading next-sanity without changing old props
v13 is intentionally not a drop-in upgrade. Review every SanityLive prop, any custom refresh behavior, and the v12 migration notes before assuming the new defaults are active.
Reading draftMode inside use cache
Runtime request APIs do not belong inside a shared cache boundary. Resolve the perspective outside and pass a serializable option object into the cached function or component.
Using a write token for live previews
The browser token can reach the client. A write-capable token turns a preview convenience into a serious credential exposure. Use a Viewer token and scope it to the intended project and dataset.
Keeping both a webhook system and Sanity Live without ownership
Two valid revalidation systems can still duplicate work. Decide which mechanism owns published freshness, which owns draft interactivity, and what each route is tagged with. Draw the flow before debugging it.
If the site is stale without the request spike, begin with the broader Sanity-to-Next.js cache debugging guide. It separates document perspective, Sanity CDN behavior, Next.js data caching, and route output caching before you change live invalidation.
If published changes reach Sanity but not the site, the companion signed webhook and revalidateTag guide shows how to trace the explicit on-demand path.
The production decision
For a new Next.js 16 application, use next-sanity v13 and model Cache Components deliberately. For an existing v12 application, contain the live component to draft sessions, move published invalidation to precise sync tags, and schedule the breaking upgrade instead of leaving the workaround indefinitely.
The important shift is conceptual: freshness is not the same as immediate global expiration. A reliable content platform updates the smallest correct cache surface, preserves stable client navigation, and makes preview behavior explicit. Once those boundaries are visible, request volume becomes predictable again.
Keep one dashboard view that overlays document publishes, Sanity requests, RSC requests, and ISR writes. It turns the next caching regression from a billing surprise into a short, explainable incident.
