How I wire next/image to Sanity hotspot focal point data for pixel-perfect crops

May 16, 2026 · 6 min read

Sanity's hotspot and crop fields exist for one reason: editors should be able to mark the subject of a photo once, and every downstream size should respect that mark. The problem is that next/image knows nothing about Sanity's coordinate system out of the box, so you have to wire them together yourself. Here's the exact approach I use on production sites to keep subjects centred across breakpoints without layout shift.

What Sanity stores and what next/image needs

When an editor clicks the hotspot tool in Sanity Studio, two objects are written to the document:

  • hotspot{ x, y, width, height } where x and y are 0–1 fractions of the original image dimensions.
  • crop{ top, bottom, left, right } fractions that trim the raw asset before delivery.

next/image accepts an object-position CSS property via its style prop, but it works in percentages of the rendered container — which maps almost directly to Sanity's hotspot.x and hotspot.y values. So the translation is cheap: multiply by 100 and append %.

The @sanity/image-url builder handles the crop rectangle server-side when you call .rect() or let the CDN do it via URL parameters. My preference is to let imageUrlBuilder apply the crop and pass the resulting URL to next/image, then use object-position to keep the hotspot centred in whatever box next/image renders into.

Setting up the image URL builder

Install the package once:

npm i @sanity/image-url

Then create a small utility module:

// lib/sanity-image.ts
import imageUrlBuilder from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
import { client } from './sanity-client' // your configured createClient instance
 
const builder = imageUrlBuilder(client)
 
export interface HotspotCrop {
  hotspot?: { x: number; y: number; width: number; height: number } | null
  crop?: { top: number; bottom: number; left: number; right: number } | null
}
 
/**
 * Returns the CDN URL with crop applied and the CSS object-position
 * string derived from the hotspot coordinates.
 */
export function getSanityImageProps(
  source: SanityImageSource & HotspotCrop,
  width: number,
  quality = 80
) {
  const { hotspot, crop } = source as HotspotCrop
 
  let urlBuilder = builder.image(source).width(width).quality(quality).auto('format')
 
  if (crop) {
    // Apply the editor's crop rectangle so the CDN trims the raw file.
    urlBuilder = urlBuilder.crop('freeform').rect(
      // @sanity/image-url rect() wants pixel values; we skip that and
      // rely on the crop object being forwarded automatically when
      // the source contains asset + crop fields.
      0, 0, 0, 0 // placeholder — see note below
    )
    // Simpler: just pass the whole image object; the builder reads
    // _sanityAsset, crop, and hotspot automatically.
    urlBuilder = builder
      .image(source)
      .width(width)
      .quality(quality)
      .auto('format')
  }
 
  const src = urlBuilder.url()
 
  const objectPosition =
    hotspot ? `${Math.round(hotspot.x * 100)}% ${Math.round(hotspot.y * 100)}%` : '50% 50%'
 
  return { src, objectPosition }
}

One clarification on the builder: when you pass the full Sanity image object (the field value that contains asset, crop, and hotspot keys), imageUrlBuilder reads the crop rectangle automatically and applies it to the CDN URL. You do not need to call .rect() manually. The CDN returns the cropped pixel region, and then object-position steers the hotspot within whatever CSS container next/image occupies.

Using the helper in a Next.js component

// components/sanity-image.tsx
import Image from 'next/image'
import { getSanityImageProps, type HotspotCrop } from '@/lib/sanity-image'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
 
interface Props {
  image: SanityImageSource & HotspotCrop & { alt?: string }
  width: number
  height: number
  sizes: string
  priority?: boolean
  className?: string
}
 
export function SanityImage({ image, width, height, sizes, priority, className }: Props) {
  const { src, objectPosition } = getSanityImageProps(image, width)
 
  return (
    <div
      className={className}
      style={{ position: 'relative', overflow: 'hidden', width: '100%', aspectRatio: `${width}/${height}` }}
    >
      <Image
        src={src}
        alt={image.alt ?? ''}
        fill
        sizes={sizes}
        priority={priority}
        style={{ objectFit: 'cover', objectPosition }}
      />
    </div>
  )
}

A few decisions worth explaining:

fill instead of explicit width/height. The outer div with a fixed aspect-ratio reserves the space in the document before the image loads, which is what kills CLS. next/image in fill mode injects position: absolute; inset: 0 on the <img> element, so it fills the container exactly.

object-position on the <img>. This is the hotspot translation. If an editor marks a face at 30% from the left and 20% from the top, object-position: 30% 20% keeps that point pinned when the browser letterboxes or pillboxes the image inside the container.

sizes prop. Always pass a realistic sizes string. Something like "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" tells Next.js which widths to generate in its srcset. Without it, the browser downloads a much larger file than it needs, which directly hurts LCP.

GROQ projection to pull only what you need

Don't fetch the entire image asset. This projection pulls the minimum fields the builder needs:

// In your page or component query
{
  "image": image {
    asset->{ _id, url, metadata { dimensions } },
    hotspot,
    crop,
    alt
  }
}

The asset-> dereference fetches dimensions so you can compute aspect ratios server-side if you want to set width and height on a non-fill image. For the fill pattern above it's optional, but I always include dimensions so I can drive the aspect-ratio CSS from real data rather than hardcoding it.

Avoiding the common pitfalls

Missing overflow: hidden on the container. If you forget it, the image bleeds outside the reserved space and object-position moves it in a way editors can see but you can't debug easily. Always pair fill with a relatively-positioned, overflow-hidden parent.

Passing hotspot values outside 0–1. Sanity Studio writes normalised fractions, but if you're importing legacy data or using a custom input, validate the range. A hotspot of x: 1.2 will push the image off-screen.

Not forwarding the full image object to the builder. If you pass only asset._ref as the source, the builder ignores crop and hotspot. Pass the whole field value — the object with asset, crop, and hotspot keys — and the builder wires them up automatically.

Over-generating srcset widths. The default Next.js deviceSizes config is broad. For a blog that maxes out at 800 px wide, trim the config in next.config.ts to avoid serving unnecessarily large files to the Sanity CDN.

Once the wiring is in place, editors can move the hotspot handle freely in Studio and every breakpoint crop adjusts without a code change — which is the actual promise of the hotspot feature.

Related posts

All posts →