The blog index looks fine on a laptop and takes eleven seconds on a phone. The network panel shows four images at three to five megabytes each, and every card jumps as they land.
The query behind it is the obvious one:
mainImage { "url": asset->url, alt }
asset->url is the URL of the original file. The photographer's 4000x3000 JPEG,
served at full resolution into a 320-pixel-wide card.
Sanity's image CDN is a URL builder
Every asset is available at any size, crop and format: the transform is expressed in the URL, and the result is cached at the edge. You do not resize anything yourself and you do not store variants.
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
const builder = createImageUrlBuilder({ projectId, dataset });
export function imageUrl(source: SanityImageSource, width: number, height?: number): string {
const url = builder.image(source).width(width).auto("format").quality(80).fit("crop");
return height === undefined ? url.url() : url.height(height).url();
}
createImageUrlBuilder is the named export from @sanity/image-url v2. The
default export (imageUrlBuilder) still works but is deprecated, and the old
deep import @sanity/image-url/lib/types/types is gone: SanityImageSource
comes from the package root now.
What each call is buying:
.width(n): the single biggest win. Ask for the size you render..auto("format"): serves AVIF or WebP to browsers that accept them and JPEG to the rest, from theAcceptheader. Typically 30-50% smaller than JPEG at the same quality..quality(80): the default is 75; 80 is a good balance for photography. Below 60 you can see it on gradients..fit("crop"), with a width and a height, crop rather than letterbox, respecting the hotspot the editor set in the Studio.
Use the LQIP that already exists
Sanity computes a Low Quality Image Placeholder for every upload: a ~20px version encoded as a base64 data URI, stored in the asset's metadata. It is about 500 bytes, it is already in the database, and it costs one field in the projection:
mainImage {
alt,
hotspot,
asset->{ _id, metadata { lqip, dimensions } }
}
Hand it to next/image as the blur placeholder and the layout jump goes away:
no extra request, no client-side blur library:
export function SanityImage({ image, width, height, sizes, priority }: Props) {
const asset = image?.asset;
if (!asset) return null;
const lqip = asset.metadata?.lqip;
return (
<Image
src={imageUrl({ asset: { _ref: asset._id } }, width, height)}
alt={image?.alt ?? ""}
width={width}
height={height}
sizes={sizes}
priority={priority}
{...(lqip ? { placeholder: "blur" as const, blurDataURL: lqip } : {})}
/>
);
}
metadata.dimensions is worth projecting alongside it: aspectRatio lets you
compute a height for a known width, which is how you reserve the right box
before anything loads.
The four remaining mistakes
No sizes on a responsive image. Without it, next/image assumes the image
is full-viewport-width and requests a source far larger than the slot it goes
into. Describe the layout: sizes="(min-width: 768px) 720px, 100vw".
priority on everything, or on nothing. The one image above the fold on a
post page should have priority so it is not lazy-loaded: it is usually the
Largest Contentful Paint element. Every other image should not: preloading a
gallery hurts the metric you were trying to fix.
Missing alt text. Make alt a required field in the schema, on the image
object itself:
defineField({
name: "mainImage",
type: "image",
options: { hotspot: true },
fields: [defineField({ name: "alt", type: "string", validation: (rule) => rule.required() })],
});
Enforcing it in the schema means editors cannot publish without it. Enforcing it in the component means you get an empty string forever.
hotspot: true set but ignored. Turning hotspots on lets an editor choose
the focal point; you have to crop with a width and height for it to have any
effect. A .width()-only URL never crops, so the hotspot does nothing.
Confirming the fix
- Load the blog index on a throttled connection with the network panel open.
Image responses should be tens of kilobytes, not megabytes, and the content
type should be
image/aviforimage/webpin a modern browser. - Check for layout shift: with the blur placeholder and explicit dimensions, Cumulative Layout Shift from images should be zero.
- Look at one image URL in the panel. It should contain
w=andauto=format. If it looks likecdn.sanity.io/images/<project>/<dataset>/<hash>-4000x3000.jpgwith no query string, something is still renderingasset->urldirectly.
Grep for it before you close the ticket:
grep -rn "asset->url" sanity/lib/queries.ts
The only place that is defensible is a tiny avatar where the original is already
small, and even there, asking for width(64) costs nothing.