
Improving Site Performance With Sanity Caching
To make a Sanity-powered site fast, cache at every layer and invalidate precisely. Read published content through the API CDN, cache query results in your framework (in Next.js, with defineLive or tagged fetches that a webhook revalidates with revalidateTag()), serve rendered pages from your host's CDN with sensible Cache-Control headers, and keep GROQ queries lean so cache misses are cheap. Images should go through Sanity's asset CDN at the size and format the page actually needs. Done well, most visitors never trigger a request to Sanity at all.
Sanity itself is quick, but every uncached query adds latency to a page and counts toward your API quota. Caching solves both, as long as new content still appears soon after editors publish. This guide covers the caching layers in a typical stack, how to choose a revalidation strategy, cache tags in Next.js with working code, caching outside Next.js, query and image optimisation, and how to debug stale content.
The Caching Layers in a Sanity Stack
A request from a visitor can be answered at any of these layers. The closer to the visitor, the faster and cheaper:
| Layer | What it caches | Who controls it | How it's invalidated |
|---|---|---|---|
| Browser | Pages, assets | Cache-Control headers | Expiry, new URLs |
| Host CDN (Vercel, Netlify, Cloudflare) | Rendered HTML, route responses | Framework and headers | Revalidation, purge, expiry |
| Framework data cache | Query results on the server | Your code (revalidate, tags) | revalidateTag(), revalidatePath(), timers |
| Sanity API CDN | Query responses | useCdn: true | Automatically, shortly after content changes |
| Sanity asset CDN | Image and file transforms | URL parameters | New asset, new URL |
| Content Lake | Source of truth | Sanity | Not applicable |
Two things follow from this table. First, layers are independent, so a stale page can come from any of them. Second, the biggest wins are in the layers you control: the framework cache and the host CDN. The Sanity API CDN helps with whatever still gets through, as explained in Sanity CDN vs Live API.
Choose a Revalidation Strategy
You need to answer one question for every query: how does the cache find out the content changed?
| Strategy | Freshness | Effort | Good for |
|---|---|---|---|
| Static build plus rebuild on publish | After the rebuild finishes | Low | Small sites, docs, portfolios |
| Time-based revalidation | Up to the interval you set | Low | Content that changes rarely |
| Tag-based revalidation from a webhook | Seconds | Medium | Most content sites |
Live Content API with defineLive | Near real time | Low in Next.js | Sites that want instant updates and previews |
For Next.js, Sanity recommends defineLive for most apps because it handles caching, revalidation and live updates in one place. Manual tags are the better fit when you want explicit control, have a mostly static site or don't want a live connection in the browser. Both are covered below. Full rebuilds on publish are covered in Triggering Site Rebuilds With Sanity Webhooks.
Automatic Caching With defineLive
defineLive from next-sanity/live returns a sanityFetch helper and a <SanityLive /> component. Queries made with sanityFetch are cached using the sync tags the Content Lake returns with each result. When content behind those tags changes, the Live Content API tells <SanityLive />, which revalidates just the affected data.
// src/sanity/lib/live.ts
import { defineLive } from "next-sanity/live";
import { client } from "./client";
const token = process.env.SANITY_API_READ_TOKEN;
if (!token) throw new Error("Missing SANITY_API_READ_TOKEN");
export const { sanityFetch, SanityLive } = defineLive({
client,
serverToken: token,
browserToken: token,
});
// src/app/layout.tsx
import { SanityLive } from "@/sanity/lib/live";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<SanityLive />
</body>
</html>
);
}
// src/app/blog/page.tsx
import { defineQuery } from "next-sanity";
import { sanityFetch } from "@/sanity/lib/live";
const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]
| order(publishedAt desc)[0...20]{ _id, title, "slug": slug.current }`);
export default async function BlogPage() {
const { data: posts } = await sanityFetch({ query: POSTS_QUERY });
return (
<ul>
{posts.map((post) => (
<li key={post._id}>{post.title}</li>
))}
</ul>
);
}
By default in next-sanity v13, revalidation uses stale-while-revalidate, so the first visitor after a change may still see the previous version while the new one renders. Sanity documents a Sync Tag Invalidate Function plus waitFor="function" on <SanityLive /> for cases where every visitor must see the update immediately. If your app enables cacheComponents, sanityFetch must run inside 'use cache' boundaries; the next-sanity guide covers that setup.
The token here must be a Viewer token, because it's sent to the browser in Draft Mode. See securing your Sanity project for why.
Manual Cache Tags in Next.js
If you'd rather control caching yourself, wrap client.fetch and pass Next.js cache options:
// src/sanity/lib/fetch.ts
import "server-only";
import type { QueryParams } from "next-sanity";
import { client } from "./client";
export async function cachedFetch<const QueryString extends string>({
query,
params = {},
tags,
revalidate = 3600,
}: {
query: QueryString;
params?: QueryParams;
tags?: string[];
revalidate?: number | false;
}) {
return client.fetch(query, params, {
next: {
// With tags, cache until a webhook revalidates; otherwise use a timer
revalidate: tags?.length ? false : revalidate,
tags,
},
});
}
I've named it cachedFetch so it isn't confused with the sanityFetch from defineLive. Don't use both approaches in the same project.
Design Your Tags
Tags are just strings, so you choose the granularity. A layered scheme works well:
- Type tags, such as
postorauthor: invalidate every query that lists that type. - Document tags, such as
post:my-slug: invalidate one page without touching the rest. - Global tags, such as
settings: for navigation, footers and site settings used everywhere.
// src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { defineQuery } from "next-sanity";
import { cachedFetch } from "@/sanity/lib/fetch";
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
body,
"author": author->{ name, "slug": slug.current }
}`);
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await cachedFetch({
query: POST_QUERY,
params: { slug },
tags: [`post:${slug}`, "author"],
});
if (!post) notFound();
return <h1>{post.title}</h1>;
}
The page depends on its own post and on author data, so it's tagged with both. Updating any author invalidates it, while updating another post doesn't.
Revalidate Tags From a Webhook
Create a webhook in Manage that fires on create, update and delete for the types you cache, with a projection that sends what the handler needs:
{ _type, "slug": slug.current }
Then handle it in a Route Handler. In Next.js 16, revalidateTag() takes a second argument; 'max' gives stale-while-revalidate behaviour:
// src/app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
import { parseBody } from "next-sanity/webhook";
type Payload = { _type: string; slug?: string };
export async function POST(req: NextRequest) {
const secret = process.env.SANITY_REVALIDATE_SECRET;
if (!secret) return new Response("Missing secret", { status: 500 });
const { isValidSignature, body } = await parseBody<Payload>(
req,
secret,
true,
);
if (!isValidSignature)
return new Response("Invalid signature", { status: 401 });
if (!body?._type) return new Response("Bad request", { status: 400 });
const tags = [body._type];
if (body.slug) tags.push(`${body._type}:${body.slug}`);
for (const tag of tags) revalidateTag(tag, "max");
return NextResponse.json({ revalidated: tags });
}
The third argument to parseBody waits briefly before returning, so the Content Lake and API CDN have caught up before pages refetch. Without it, a revalidated page can cache stale data again. The Sanity webhooks post explains filters, projections and signatures in more depth.
Keep a Safety Net
Pure tag-based caching lives until invalidated. If a webhook ever fails, a page can stay stale indefinitely. Two cheap safeguards:
- Monitor webhook deliveries: Manage shows recent attempts and failures for each webhook.
- Add a long time-based fallback for critical routes, such as
export const revalidate = 86400on the page, so nothing is ever more than a day old.
Caching Outside Next.js
Other frameworks lean on HTTP caching instead of a built-in data cache. Set Cache-Control on server-rendered responses so your host CDN does the work:
// Example: a server route in Astro, SvelteKit or Remix
export async function GET() {
const posts = await client.fetch(POSTS_QUERY);
return new Response(JSON.stringify(posts), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400",
},
});
}
s-maxage applies to shared caches like a CDN, and stale-while-revalidate lets the CDN serve the old copy while fetching a new one in the background. For on-demand invalidation, call your host's purge API from a Sanity webhook. For fully static sites, rebuilding on publish is often simpler; see How to Use Sanity With Astro.
Make Cache Misses Cheap
Every cache eventually misses, so the query behind it should be quick.
- Project only what you render:
*[_type == "post"]returns every field, including full Portable Text bodies. A list page needs{ _id, title, "slug": slug.current }. The GROQ projections guide shows the patterns. - Slice early:
| order(publishedAt desc)[0...20]returns 20 documents instead of all of them. Use the techniques in pagination with GROQ for long lists. - Fetch related data in one query: dereference with
author->nameinstead of making a second request per post. - Combine page data: one query returning
{ "posts": ..., "settings": ... }is one round trip instead of two. - Keep query text stable: use parameters for values. Interpolated values create a new cache key for every variation, at every layer.
- Limit build concurrency: static builds that fire thousands of parallel queries can hit live API concurrency limits. Batch or queue them.
Optimise Images
Images are usually the heaviest part of a page, and they're cached separately on Sanity's asset CDN. Every transformation URL is cached once generated, so consistent parameters improve hit rates:
import Image from "next/image";
import type { SanityImageSource } from "@sanity/image-url";
import { urlFor } from "@/sanity/lib/image";
export function Cover({
image,
alt,
}: {
image: SanityImageSource;
alt: string;
}) {
return (
<Image
src={urlFor(image)
.width(1200)
.height(675)
.fit("crop")
.auto("format")
.url()}
alt={alt}
width={1200}
height={675}
sizes="(min-width: 768px) 768px, 100vw"
preload
/>
);
}
Use auto('format') to serve modern formats, request the size you display, and use preload only for the main image above the fold. The Sanity image optimisation post covers responsive srcSet values, LQIP placeholders and the next-sanity/image loader.
Debug Stale Content
When an update doesn't appear, work from the source outwards:
- Is it published? Query with Vision using the published perspective.
- Does the live API have it? Fetch from
api.sanity.iodirectly. If yes, the Content Lake is fine. - Does the API CDN have it? If only the live API does, wait a moment or check you aren't fetching from the CDN in revalidation code.
- Did the webhook fire? Check delivery attempts in Manage and your function logs.
- Is the framework cache stale? Turn on fetch logging in Next.js:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
logging: {
fetches: { fullUrl: true },
},
};
export default nextConfig;
The logs show each fetch, whether it was a cache hit or miss, and its tags. Unexpected hits after a webhook usually mean a tag mismatch: check the exact strings on both sides.
- Is the host CDN or browser serving an old page? Check response headers such as
AgeandCache-Control, and try a private window.
Measure the Results
Watch two numbers. In Sanity's usage dashboard in Manage, API and API CDN request counts should stay roughly flat as traffic grows, because caches absorb the extra load. If API requests climb with traffic, something is bypassing the cache. On the site side, track Time to First Byte and Largest Contentful Paint; cached HTML should make TTFB consistently low, and properly sized images improve LCP.
FAQ: Sanity Caching and Performance
Not for cache revalidation. defineLive uses sync tags and the Live Content API to revalidate affected queries when content changes. You might still use webhooks for other jobs, such as purging an external CDN, rebuilding a search index or notifying a team.
In Next.js 16 the single-argument form is deprecated. Pass a cache life profile, usually 'max', which marks tagged data as stale and refreshes it in the background on the next visit using stale-while-revalidate semantics.
Both. Type tags such as post make list pages easy to invalidate, and document tags such as post:my-slug let you refresh one page without invalidating every post. Add a global tag for site settings and navigation.
For content that changes rarely, yes. A one-hour or one-day interval is simple and reliable. For news, products or anything editors expect to see live quickly, use tag-based revalidation or defineLive, optionally with a long time-based fallback.
Usually. Cached pages don't call Sanity at all, and requests that do get through are cheaper on the API CDN, which has a much larger included quota than the live API. Uncached server rendering of every request is the most common cause of overages.
The page probably refetched before Sanity's caches caught up, or the tag strings didn't match. Use parseBody with its propagation delay, fetch with useCdn false in revalidated paths if needed, and compare the tags in your fetch logs with the ones your handler revalidates.
Conclusion
Fast Sanity sites cache at every layer: the host CDN in front of rendered pages, the framework cache in front of queries, and the Sanity API CDN and asset CDN behind them. In Next.js, defineLive gives you most of this automatically, while manual cache tags with a signed webhook and revalidateTag(tag, 'max') give you precise, explicit control.
Whichever route you take, keep queries lean, parameters stable and images properly sized, so the occasional cache miss is still quick. Add fetch logging and a usage check to your routine, and you'll catch the queries that slip past the cache before they show up in your performance scores or your bill.


