Skip to content

Sanity images without the 4MB original and the layout jump

asset->url hands the browser the raw upload. Build a transform URL with a width, and use the LQIP Sanity already generated as the blur placeholder.

Sanity blog3 min readships at docs/solutions/blog-sanity/image-urls-and-lqip.md

Tags: sanity · images · performance · lqip · next-image

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 the Accept header. 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

  1. 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/avif or image/webp in a modern browser.
  2. Check for layout shift: with the blur placeholder and explicit dimensions, Cumulative Layout Shift from images should be zero.
  3. Look at one image URL in the panel. It should contain w= and auto=format. If it looks like cdn.sanity.io/images/<project>/<dataset>/<hash>-4000x3000.jpg with no query string, something is still rendering asset->url directly.

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.