Sanity's image CDN and Next.js both offer automatic WebP and AVIF conversion. When you stack them naively — passing a Sanity URL through next/image without thinking — you end up with double-encoding: Vercel's optimisation layer fetches an already-optimised AVIF from Sanity, re-encodes it, and caches a bloated result under a cache key you can't easily predict. I've debugged this on three production sites; here is the setup that avoids it.
Why double-encoding is a real problem
Next.js Image optimisation works by fetching the src URL you pass in, running it through Sharp (or the Vercel edge equivalent), and caching the result keyed on src + width + quality. If that src already points to an AVIF served by Sanity's CDN, Sharp receives a compressed AVIF as input. It decodes it, loses some quality to generation loss, re-encodes it, and stores the result. The cached file is often larger than what Sanity would have served directly, and the /api/image route adds a round-trip for every unique src string.
The practical symptoms are a slow LCP on first uncached load, a 2–4× cache storage increase on Vercel's image cache, and confusing Lighthouse scores where the "serve images in modern formats" audit fires even though your CDN is already doing it.
Understanding Sanity's auto=format
Sanity's image URL builder exposes an auto('format') method. When you call it, the CDN sniffs the Accept header on the incoming request and returns AVIF, WebP, or JPEG accordingly — no query-string change, same URL, content-negotiated response.
// lib/sanity-image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './sanity-client'
const builder = imageUrlBuilder(client)
export function urlFor(
source: SanityImageSource,
width: number,
quality = 80
) {
return builder
.image(source)
.width(width)
.quality(quality)
.auto('format') // CDN negotiates AVIF/WebP/JPEG per Accept header
.url()
}
The URL produced looks like:
https://cdn.sanity.io/images/<projectId>/<dataset>/abc123.jpg?w=800&q=80&auto=format
Notice the extension is still .jpg. The format decision happens at the edge, not in the URL. That matters for cache keys, which I'll explain below.
When to bypass next/image entirely
If the image is above-the-fold and you have exact width/height from Sanity metadata, you can skip next/image and render a plain <img> with a srcSet built from Sanity URLs. You lose lazy loading and the built-in blur placeholder, but you gain:
- Zero Vercel image optimisation costs
- Sanity CDN cache hits instead of Vercel cache hits (better global PoP distribution)
- A stable, predictable URL for
<link rel="preload">
// components/SanityCdnImage.tsx
import { urlFor } from '@/lib/sanity-image'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
interface Props {
source: SanityImageSource
alt: string
width: number
height: number
priority?: boolean
}
const WIDTHS = [400, 800, 1200, 1600]
export function SanityCdnImage({ source, alt, width, height, priority }: Props) {
const srcSet = WIDTHS.map((w) => `${urlFor(source, w)} ${w}w`).join(', ')
const src = urlFor(source, width)
return (
<img
src={src}
srcSet={srcSet}
sizes="(max-width: 768px) 100vw, 800px"
alt={alt}
width={width}
height={height}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
fetchPriority={priority ? 'high' : undefined}
/>
)
}
This works well for hero images where you know width and height at build time from Sanity's asset.metadata.dimensions. For thumbnails in a grid where layout shifts are unlikely and you want lazy loading managed by the browser, this is all you need.
When to keep next/image in the pipeline
next/image is worth keeping for:
- Images served from a remote origin that isn't Sanity (user-uploaded avatars, partner logos)
- Cases where you want the automatic
blurplaceholder without rolling your own LQIP - Responsive images inside MDX or Portable Text where you don't control the render context
If you do use next/image with a Sanity src, configure the loader to strip auto=format and let Next.js handle format negotiation instead of Sanity. Mixing both means the Accept header is set by Vercel's optimisation server, not the browser, so content negotiation still works — but Sanity will serve AVIF to Vercel's server regardless of the end user's browser support, which is fine but redundant.
The cleaner approach is to set a custom loader that removes auto=format and adds explicit format parameters based on the loader's context:
// lib/sanity-next-loader.ts
import type { ImageLoaderProps } from 'next/image'
export function sanityLoader({ src, width, quality }: ImageLoaderProps): string {
const url = new URL(src)
// Remove auto=format — next/image handles format negotiation
url.searchParams.delete('auto')
url.searchParams.set('w', String(width))
url.searchParams.set('q', String(quality ?? 80))
return url.toString()
}
Pass it as loader={sanityLoader} on any <Image> component that uses a Sanity CDN URL. Now the cache key Next.js builds is based on a plain JPEG URL, and Sharp receives the JPEG — not an AVIF — as input. Quality loss from re-encoding is minimal, and the output size is predictable.
Cache key hygiene
Sanity's CDN caches on the full URL including query parameters. auto=format doesn't change the cache key because format selection is header-based at the Sanity edge — same URL, different response body per client. That means two users with different browser support hit the same Sanity URL and get format-appropriate responses without busting the cache.
Vercel's image cache, on the other hand, keys on the src string as passed to <Image>. If you let auto=format stay in the URL and route through next/image, Vercel caches one response per src + width + quality combination, and that cached response is whatever format Vercel's server negotiated with Sanity (almost always AVIF). Users on older browsers that don't support AVIF will still receive the correct format because the browser never sees the Sanity URL directly — it sees the Vercel /api/image URL. So it isn't broken, just wasteful.
The rule I follow on every project
- Above-the-fold images with known dimensions: plain
<img>+ Sanity CDN +auto=format. Preload with<link rel="preload" as="image" imagesrcset="...">. - Below-the-fold images in grids or listings: same, but no preload.
-
next/imageonly when you need blur placeholders or are sourcing from a non-Sanity origin. Use the custom loader to stripauto=format.
This keeps Sanity's CDN doing the format work it's already optimised for, avoids re-encoding overhead on Vercel, and gives you a clear mental model for where each image's cache lives.
Top comments (0)