How I derive sizes, width, and height from Sanity metadata to kill CLS
May 18, 2026 · 6 min read
Layout shift on images is almost always a width/height problem. When next/image doesn't know the intrinsic dimensions up front, the browser can't reserve space, and the page jumps once the image loads. Sanity stores exact pixel dimensions on every uploaded asset — in metadata.dimensions — so there's no excuse for guessing.
Why metadata.dimensions is the right source of truth
Every Sanity image asset has a metadata object written at upload time. The dimensions field gives you width, height, and aspectRatio as numbers. These never change unless someone re-uploads the file, so they're safe to project in GROQ and pass straight to <Image>.
The common mistake is projecting only asset._ref or the URL and then hard-coding dimensions in the component. The moment an editor swaps a landscape image for a portrait one, you get a wrong aspect ratio and CLS.
Here's the GROQ projection I use on every image field:
// Used inside any document query
image {
asset->{
url,
metadata {
dimensions {
width,
height,
aspectRatio
},
lqip
}
},
hotspot,
crop
}Project lqip while you're here — it's a base64 blur placeholder that costs nothing extra and improves perceived LCP. The hotspot and crop fields matter if you've implemented focal-point cropping, but the focus of this post is the dimension side.
Typing the response and building the image URL
With Sanity TypeGen you'd get this for free, but here's a manual type so the component stays self-contained:
// lib/sanity.image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './sanity.client'
const builder = imageUrlBuilder(client)
export interface SanityImageData {
asset: {
url: string
metadata: {
dimensions: { width: number; height: number; aspectRatio: number }
lqip?: string
}
}
hotspot?: { x: number; y: number }
crop?: { top: number; bottom: number; left: number; right: number }
}
/**
* Returns a Sanity image URL with format and quality set.
* Width is added by the calling component via `.width(n)`.
*/
export function urlFor(source: SanityImageData) {
return builder.image(source).auto('format').quality(80)
}
/**
* Derives the rendered pixel dimensions after Sanity crop is applied.
* Crop values are fractions (0–1). If no crop, returns the raw asset dimensions.
*/
export function croppedDimensions(image: SanityImageData): {
width: number
height: number
aspectRatio: number
} {
const { width, height } = image.asset.metadata.dimensions
const crop = image.crop ?? { top: 0, bottom: 0, left: 0, right: 0 }
const croppedWidth = Math.round(width * (1 - crop.left - crop.right))
const croppedHeight = Math.round(height * (1 - crop.top - crop.bottom))
return {
width: croppedWidth,
height: croppedHeight,
aspectRatio: croppedWidth / croppedHeight,
}
}croppedDimensions matters because Sanity's crop field is applied client-side when you build the URL. If you pass the raw asset width/height to next/image but the URL has a crop applied, the aspect ratio will be wrong and you'll get CLS.
The responsive image component
Here's the full component I use for editorial images — hero banners, article body images, card thumbnails. It handles three common patterns: full-width, constrained, and fixed-size.
// components/SanityImage.tsx
import Image from 'next/image'
import { urlFor, croppedDimensions, type SanityImageData } from '@/lib/sanity.image'
interface Props {
image: SanityImageData
/**
* Pass a `sizes` string that matches your CSS layout.
* Defaults to full viewport width — override for constrained contexts.
* Examples:
* "100vw" — full bleed
* "(min-width: 1280px) 1200px, 100vw" — max-width container
* "(min-width: 768px) 50vw, 100vw" — 2-col grid
* "320px" — fixed thumbnail
*/
sizes?: string
/** CSS class applied to the wrapper div that holds the aspect ratio */
className?: string
priority?: boolean
alt: string
}
export function SanityImage({
image,
sizes = '100vw',
className = '',
priority = false,
alt,
}: Props) {
const { width, height } = croppedDimensions(image)
// Build a srcset-ready URL. next/image calls the loader for each breakpoint.
// We supply the full-resolution URL; next/image's built-in loader adds w= params.
const src = urlFor(image).url()
return (
<div
className={`relative overflow-hidden ${className}`}
// Inline aspect-ratio prevents CLS even before the image src resolves.
style={{ aspectRatio: `${width} / ${height}` }}
>
<Image
src={src}
alt={alt}
fill
sizes={sizes}
placeholder={image.asset.metadata.lqip ? 'blur' : 'empty'}
blurDataURL={image.asset.metadata.lqip}
priority={priority}
/>
</div>
)
}A few decisions worth explaining:
fill + aspect-ratio wrapper instead of explicit width/height — Using fill means next/image renders a position: absolute <img> that fills its parent. The parent has position: relative and an inline aspect-ratio. The browser can calculate the reserved space immediately from the CSS, so there's no shift. This is cleaner than passing numeric width/height to fill={false} because you don't need to know the rendered CSS pixel width at the call site — you just know the intrinsic ratio.
The sizes prop is the most important thing you control — next/image uses sizes to decide which srcset variant to download. If you lie here (or use the default 100vw for a 400 px thumbnail), the browser fetches a 1200 px image for a 400 px slot. Use real breakpoints. For a two-column card grid that goes full-width below 768 px:
sizes="(min-width: 768px) 50vw, 100vw"For a max-width-1280 container:
sizes="(min-width: 1280px) 1200px, 100vw"For a fixed 80 px avatar that never changes size:
sizes="80px"Getting this wrong doesn't cause CLS, but it causes LCP regression and wastes bandwidth — which is why it belongs in the same conversation as dimension handling.
Checking your work in the browser
Open DevTools Network tab, filter by Img, and look at the w= parameter in the Sanity CDN URL next/image requests. It should be close to the actual rendered pixel width × device pixel ratio. If you see w=3840 for a 400 px card, your sizes is wrong. If the image jumps down when it loads, aspectRatio isn't being set — check that croppedDimensions is returning non-zero values and that the wrapper div has position: relative.
You can also run Lighthouse with CPU throttling and watch for CLS attributed to images. A correctly configured <SanityImage> should contribute 0 to CLS on any layout.
Handling fill-mode vs. fixed-size in the same schema field
Some layouts need a fixed pixel image (a logo, an icon, an avatar) rather than a fluid one. For those cases, skip the wrapper entirely and use numeric width/height on <Image fill={false}>:
// For fixed-size images where you know the rendered dimensions
const { width: intrinsicW, height: intrinsicH } = croppedDimensions(image)
// Scale down proportionally to the max rendered size
const maxW = 160
const scale = Math.min(1, maxW / intrinsicW)
const renderedW = Math.round(intrinsicW * scale)
const renderedH = Math.round(intrinsicH * scale)
;<Image
src={urlFor(image).width(renderedW * 2).url()} // 2× for retina
alt={alt}
width={renderedW}
height={renderedH}
/>Passing width and height here tells both the browser and next/image exactly how much space to reserve. No wrapper needed.
The single rule to remember
Every <Image> in your app should have its reserved space derivable from static information — either inline width/height on the element, or a position: relative parent with an aspect-ratio set before the image loads. Sanity's metadata.dimensions gives you that static information for free. Project it in GROQ, pass it through your component, and you'll never debug image-induced CLS again.
Related posts
All posts →How I wire next/image to Sanity hotspot focal point data for pixel-perfect crops
May 16, 2026 · 6 min read
Use Sanity's hotspot data with next/image to serve correctly cropped images at every breakpoint, prevent CLS, and avoid off-center subjects.
Sanity CMS + Next.js App Router: a practical getting started guide
May 11, 2026 · 6 min read
A step-by-step sanity cms nextjs tutorial for developers setting up schemas, GROQ queries, and Sanity Studio with the App Router for the first time.
How I Eliminated Sanity Image Hot-Spot Reflows by Pre-Calculating Focal Crops
May 08, 2026 · 5 min read
How I pre-calculate Sanity image hot-spot crops at build time to eliminate layout shift and guarantee stable LCP under 2.5s on editorial pages.