← All notes
Frontend Development8 min

Don't run the image through two optimizers

On Provale Cup and on this site, photos live in Sanity and render through Next.js. The default stack — urlFor into next/image — quietly becomes two pipelines: the CMS CDN transforms the asset, then the framework optimizer transforms it again. One of those is usually enough. The work is deciding which one owns the bytes, then making sizes and priority tell the truth about the layout.

Two pipelines, one image

Sanity's image CDN is not a dump of the original upload. Call urlFor with a width, height, and fit, and the edge returns a resized, cropped, format-negotiated response. That is already an optimizer. next/image is also an optimizer: it builds a srcset, may re-encode through its own image endpoint, and caches the result closer to the app host.

When you pass a Sanity CDN URL into next/image without thinking about that overlap, the browser does not get "extra optimized." It gets a second transformation of a URL that was already a transformation. The common failure mode is not a broken picture. It is an LCP candidate that waits on an unnecessary hop, or a thumbnail that still downloads a width the layout never needed because sizes was left at the default.

This showed up for us on content-heavy marketing surfaces — hero photography and case-study galleries — where the CMS is the source of truth for crops and the front end is only responsible for placing the result. The stack looked correct in code review. The network panel told a different story.

Let the CMS CDN own the transform

When images are editor-owned in Sanity, the honest default is to let the Sanity CDN finish the job. Build the URL with the dimensions the layout actually uses, pass the full image object so crop and hotspot travel with it, and ask for auto format. Serve that URL with next/image marked unoptimized, or with a thin custom loader that returns the Sanity URL unchanged.

unoptimized here is not "ship the raw upload." It means "stop inventing a second resize pipeline." The component still gives you layout stability through width and height, still participates in lazy loading, and still accepts priority for the one above-the-fold candidate. What it no longer does is proxy an already-transformed asset through /_next/image.

That split matches how we already think about commerce: Shopify owns checkout; Sanity owns content. Here Sanity owns the image transform. Next owns the page shell and the loading priority. Crossing those wires is how you get two systems both believing they are responsible for the same byte budget.

Who owns the bytes
Double pipeline
  • urlFor resizes and crops at Sanity
  • next/image re-encodes through /_next/image
  • Default sizes over-fetches on mobile
  • Hotspot decisions get a second, opaque crop
One owner
  • urlFor requests the layout width once
  • next/image places and prioritizes only
  • sizes matches the real CSS box
  • Editor hotspot survives to the final crop

An optimizer is a decision. Two optimizers without a written handoff are two guesses.

When next/image still earns its keep

Local assets under /public are a different case. There is no CMS CDN in front of them. Using next/image for those — logos, UI chrome, a static hero that never goes through Sanity — is the right default, because the framework optimizer is the first transform, not the second.

Remote images from a host that only dumps originals also belong on next/image, or on a dedicated image CDN you chose on purpose. The rule is not "never use the optimizer." The rule is "do not stack two full pipelines on the same asset without a reason."

A reason we accept: you need a single srcset strategy across mixed sources and you have standardized on the Next image endpoint. Fine — then stop asking Sanity for large transforms you are about to redo, or stop asking Next to redo what Sanity already finished. Pick one. Write it down in the image component so every page inherits the same decision.

sizes is a layout claim, not a default

Whether Sanity or Next produces the bytes, sizes is still the prop that decides which candidate the browser requests. Omit it on a fill image and you are claiming the image is roughly full viewport width. On a three-column gallery that is a lie, and the browser downloads accordingly.

We treat sizes as part of the layout contract: if the CSS says the image is forty percent of the viewport above 800px, the sizes string says the same thing. The home collage, capability strips, and case-study figures on this site each carry their own sizes for that reason. Copy-pasting "100vw" into every Image is how mobile LCP quietly collapses under desktop-sized files.

priority follows the same discipline. One LCP candidate per route gets it — usually the hero. Marking every above-the-fold thumbnail priority recreates the double-optimizer problem at the network layer: everything is urgent, so nothing is.

Hotspot without fit('crop') is a lie

Editors set hotspots because they expect the face, the product, or the detail they marked to survive every crop the front end applies. That only happens if the URL builder receives the full image object — asset, crop, and hotspot — and you ask for fit('crop') when you need a fixed aspect ratio.

Pass only asset._ref, or leave fit at the default clip, and the hotspot is silently ignored. The page still renders. The wrong part of the photo is simply centered. On a medical-device product shot or a case-study still, that is not a polish issue; it is the editor's intent discarded between Studio and browser.

This is the same class of bug as server-rendering a zero and animating to the truth: the failure looks fine in the happy path and wrong in the path you did not check. Open the generated URL. If the crop parameters are missing when the layout is cropping, the pipeline is lying about who owns the frame.

One owner, written down

Put the decision in one SanityImage-style wrapper. Callers pass the Sanity image value, alt, sizes, and whether this instance is the LCP candidate. The wrapper owns urlFor, fit, dimensions, and whether next/image is allowed to re-optimize. Scattered one-off Image tags are how a project drifts back into two pipelines.

For Provale-style sites the written rule is short: Sanity CDN transforms editor images; Next places them; sizes and priority are required props, not optional niceties. Local static files may use the framework optimizer. Everything else needs a sentence explaining why.

Image performance work tends to attract tool shopping. Most of what we have needed is narrower: stop transforming twice, tell the browser the real width, protect the hotspot, and give priority to one element. That is enough to make the network panel match the layout you thought you shipped.

Questions

Should every Sanity image use next/image without unoptimized?
Not by default. Sanity's CDN already resizes, crops, and negotiates format. Use next/image for layout, lazy loading, and a single priority LCP candidate, and avoid a second encode through /_next/image unless you have deliberately standardized on that endpoint.
Why does omitting sizes hurt LCP on image-heavy pages?
Without sizes, fill images are treated as roughly full viewport width. Galleries and partial-width heroes then download candidates larger than the CSS box, which delays Largest Contentful Paint even when the CDN itself is fast.
Why is my Sanity hotspot ignored in the front end?
Usually the builder received only an asset ref, or fit stayed at clip instead of crop. Pass the full image object including crop and hotspot, and use fit('crop') when requesting a fixed aspect ratio.

Sources

  1. Sanity — Presenting images
  2. Next.js — Image Optimization
  3. web.dev — Optimize Largest Contentful Paint
  4. @sanity/image-url

Have something to build?

Tell us what you're working on and we'll tell you honestly whether we're the right fit.

Work with us