# 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.

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:

```groq
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.

```ts
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:

```groq
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:

```tsx
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:

```ts
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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
