Sanity image-url hotspot not working: four causes and fixes

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

Sanity's hotspot and crop system works well when all the pieces line up — but if your rendered image is ignoring the focal point you set in Studio, one of four things is almost certainly wrong. None of them are subtle bugs; they're all configuration mistakes that are easy to miss and easy to fix.

The four causes (and their fixes)

1. The builder isn't given both a width and a height

This is the most common cause. @sanity/image-url only uses the hotspot when the output aspect ratio is fixed, which means you must set both .width() and .height(). With both set, the builder computes a crop rectangle centred on the hotspot (and kept inside the editor's crop) and adds it to the URL as a rect= parameter. With only .width(), it just applies the editor's crop and scales the image, so there's nothing for the hotspot to do.

The other way to switch the hotspot off by accident is calling .crop(), .rect(), .focalPoint(), or .ignoreImageParams() on the builder. Any of those tells it to skip the automatic hotspot crop.

Fix: set both dimensions and leave the cropping to the builder.

// src/lib/sanity-image.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url'
import { client } from './sanity-client'
 
const builder = createImageUrlBuilder(client)
 
export function urlFor(source: SanityImageSource) {
  return builder.image(source)
}
 
// Usage — hotspot is applied because both width and height are set
const url = urlFor(image)
  .width(800)
  .height(600)
  .fit('crop')   // optional here: the rect already matches 800×600
  .auto('format')
  .url()

Without a fixed width and height, the builder doesn't add a hotspot-based rect, and the focal point has no visible effect.

2. Missing options: { hotspot: true } on the schema field

If the image field in your Sanity schema is not configured with hotspot support, Studio never renders the focal point UI, and the hotspot and crop keys are never written to the document in the first place. The URL builder can't use data that isn't there.

Fix: add options: { hotspot: true } to every image field where editors need focal control.

// schemas/post.ts
export default {
  name: 'post',
  type: 'document',
  fields: [
    {
      name: 'coverImage',
      type: 'image',
      options: {
        hotspot: true, // <-- enables the focal point UI in Studio
      },
    },
  ],
}

After adding this, existing documents will still have no hotspot data — editors need to open each document and set the focal point manually, then republish.

3. Passing only asset._ref to the URL builder instead of the full image object

This one is common when developers destructure a GROQ projection too aggressively. The URL builder needs the full image object — asset, crop, and hotspot — to apply the focal point. If you pass only the reference string or only { asset }, the builder has no crop/hotspot coordinates to work with.

// ❌ Wrong — only the reference, crop and hotspot are lost
"coverImage": coverImage.asset._ref
 
// ✅ Correct — project the full image object
"coverImage": coverImage {
  asset,
  crop,
  hotspot
}

In your component, pass the full object:

// src/components/CoverImage.tsx
import { urlFor } from '@/lib/sanity-image'
import type { SanityImageObject } from '@sanity/image-url'
 
interface Props {
  image: SanityImageObject // asset + crop + hotspot
}
 
export function CoverImage({ image }: Props) {
  const src = urlFor(image)
    .width(1200)
    .height(630)
    .fit('crop')
    .auto('format')
    .url()
 
  return <img src={src} alt="" width={1200} height={630} />
}

If you use Sanity TypeGen, the generated type for a projected image field with asset, crop, and hotspot is structurally compatible with what the builder expects — a good reason to keep TypeGen up to date.

4. Hotspot values outside the 0–1 range from a bad data import

Sanity stores hotspot as { x, y, width, height } where every value is a float between 0 and 1 (fraction of the image dimensions). If you migrated content from another system or ran a custom import script, it's possible the hotspot data was written with pixel values instead of fractional values — for example { x: 320, y: 240, width: 640, height: 480 } on a 1280×960 image instead of the correct { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }.

Pixel-valued coordinates don't cause a runtime error; the CDN just receives coordinates it can't map sensibly, so the crop lands in a wrong or extreme position.

How to verify: open the raw document in Sanity Studio (three-dot menu → Inspect, or sanity documents get <id> via the CLI) and check the hotspot values. If any of x, y, width, or height are greater than 1, the import was wrong.

Fix: write a migration script using @sanity/client to normalise the values.

// scripts/fix-hotspot-values.ts
import { createClient } from '@sanity/client'
 
const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  token: process.env.SANITY_WRITE_TOKEN!,
  apiVersion: '2024-01-01',
  useCdn: false,
})
 
const IMAGE_WIDTH = 1280
const IMAGE_HEIGHT = 960
 
const docs = await client.fetch(
  `*[_type == 'post' && defined(coverImage.hotspot)]{ _id, coverImage }`
)
 
for (const doc of docs) {
  const h = doc.coverImage.hotspot
  // Only patch if values look like pixels (> 1)
  if (h.x > 1 || h.y > 1) {
    await client
      .patch(doc._id)
      .set({
        'coverImage.hotspot': {
          x: h.x / IMAGE_WIDTH,
          y: h.y / IMAGE_HEIGHT,
          width: h.width / IMAGE_WIDTH,
          height: h.height / IMAGE_HEIGHT,
        },
      })
      .commit()
    console.log(`Patched ${doc._id}`)
  }
}

Run this against your staging dataset first. Verify a few documents in Studio before running on production.

How to confirm hotspot is working

The fastest sanity check: open browser DevTools, copy the generated image URL, and look for the rect query string parameter. A working hotspot URL will contain something like rect=320,0,1280,960 (left, top, width, height in pixels of the original image), and moving the hotspot in Studio should change those numbers. If rect is absent, either the builder wasn't given both a width and a height, or the crop the builder computed covers the whole image (it omits rect when nothing is cut off).

You can also pass the image object through the builder and log .options to confirm the builder has received all three keys before generating the URL:

const img = urlFor(image).width(800).height(600).fit('crop')
console.log(img.options) // { source: { asset, crop, hotspot }, width, height, fit, ... }

All four of the causes above produce identical symptoms — a hotspot that looks set in Studio but has no effect on the rendered image. Work through them in order: width/height first (takes ten seconds to check), schema options second, GROQ projection third, and data integrity last.

Frequently asked questions

Why is my Sanity image hotspot not being applied?

Four causes account for nearly every case: the URL builder isn't given both a width and a height (or a manual .crop()/.rect()/.focalPoint() call overrides the hotspot), the schema image field is missing options: { hotspot: true }, the code passes only asset._ref instead of the full image object with its crop and hotspot data, or hotspot values sit outside the expected 0-1 range after a bad data import.

How do I verify hotspot data is actually stored on my Sanity image?

Open the raw document in Studio (three-dot menu, then Inspect) or run sanity documents get <id> in the CLI, and check the hotspot object. All of x, y, width, and height must be between 0 and 1 — values greater than 1 mean a broken import, fixable with a normalisation script using @sanity/client.