Shopify Storefront API Image Transform: srcset and Crop

The Shopify Storefront API image transform lives on one field: Image.url(transform: ImageTransformInput). It takes maxWidth and maxHeight (1 to 5760 pixels), a crop region, a scale of 1 to 3 and a preferredContentType, and returns a Shopify CDN URL. In our mock.shop tests that was the plain URL with width, height and crop query parameters added. For a responsive srcset, fetch the plain url once and add those parameters yourself, capped at the image’s own width.

Everything below is written against Storefront API 2026-10, which Shopify’s versioning page lists as the latest stable version as of November 2026. We ran the checks on 9 November 2026 against mock.shop, Shopify’s public mock Storefront API, and fetched the image URLs it returned, with parameters varied by hand, from cdn.shopify.com with curl. We did not run them against a real merchant store, and where mock.shop and the docs part ways we say so.

What does ImageTransformInput accept?

Five fields, all optional, all described in the ImageTransformInput reference as best effort. Any transformation the source image type cannot support is ignored rather than rejected, so a wrong transform rarely shows up as an error. You find it by looking at the picture.

maxWidth and maxHeight are integers between 1 and 5760. crop takes a CropRegion: CENTER, TOP, BOTTOM, LEFT or RIGHT. scale multiplies the size for high-density screens and defaults to 1. preferredContentType takes an ImageContentType of JPG, PNG or WEBP; the documented conversions are SVG to PNG, and anything to JPG or WebP.

The Image object also carries width, height, altText and thumbhash. The first two are the original’s dimensions, and they return null when the image is not hosted by Shopify. Query them every time, because the cap on your srcset depends on them. The older src, originalSrc and transformedSrc fields are deprecated, so code still reading transformedSrc should move to url.

To get several sizes in one request, the docs point you at GraphQL aliases:

query ProductImage($handle: String!) {
  product(handle: $handle) {
    title
    featuredImage {
      url
      width
      height
      altText
      thumb: url(transform: { maxWidth: 400, maxHeight: 400, crop: CENTER })
    }
  }
}

That query passes Shopify’s Storefront GraphQL validator for 2026-10. Against mock.shop, thumb came back as the plain URL with ?width=400&height=400&crop=center appended.

Why is the image not square when maxWidth and maxHeight match?

Because the two values describe a box the image has to fit inside. Nothing gets cut off unless you also pass a crop.

We fetched a portrait product shot, 896 by 1200 pixels, from the CDN with different parameters. ?width=400&height=400 returned a 299 by 400 image: the whole photo, scaled until the taller side hit 400. Adding &crop=center returned 400 by 400. crop=top and crop=bottom were also 400 by 400, and each file was different, so the region parameter did move the crop. ?width=400&crop=top, with no height, returned the same bytes as ?width=400. One dimension gives the CDN nothing to crop against, so it drops the parameter without an error.

The reference wording is narrower than what we saw. It says crop must be used with maxWidth and/or maxHeight “where the maxWidth and maxHeight aren’t equal”. Our equal 400 by 400 request with crop=center cropped as you would hope. We tested the CDN parameters directly, not a real store’s GraphQL response, so treat a square crop as something to check on your own store before you rely on it.

If you are coming from Hydrogen, its Image component defaults crop to center and sends a height whenever you give it an aspectRatio.

Does the Shopify CDN upscale images?

No. A hand-written srcset that ignores this ends up describing images the CDN will never send.

?width=1080 on the 896-pixel source returned 896 by 1200. ?width=5000 on a 4096-pixel square returned 4096 by 4096. A srcset that lists 1080w, 1280w and 1600w candidates for that image is telling the browser about sizes that do not exist. On a wide or high-density screen the browser picks one of them and downloads the full original under a false width.

Shopify’s own theme tooling deals with this. Its theme performance guide says image_tag filters candidate widths to those no larger than the image_url output, “so the markup never asks for an upscaled image”. Hydrogen does the same: in @shopify/hydrogen-react 2026.4.4, the version whose source we read, the Image component drops any generated width larger than data.width, and any height larger than data.height. That filter only runs if your fragment asked for width and height.

Should you set preferredContentType to WEBP?

For images going into an HTML page, usually not. The CDN picks the format from the request’s Accept header.

For a 4096-pixel square image from mock.shop’s default store, ?width=400 came back as image/jpeg (10,140 bytes) with Accept: */*, and as image/webp (7,954 bytes) with Accept: image/webp,*/*. With Accept: image/avif,image/webp,*/* we still got WebP for that image, although Shopify’s guide says the CDN serves “WebP or AVIF when the browser supports them”. Every response carried Vary: Accept. Adding format=webp to the URL by hand made no difference to a client sending */*.

mock.shop behaved differently from the documentation here. It left preferredContentType and scale out of the URLs it returned, so scale: 2 gave the same ?width=400 URL as no scale at all. It also accepted maxWidth: 6000 and maxWidth: 0 without the range error the docs imply. Those are mock.shop results. A real store may encode both fields, so print the URL your store actually returns before you build anything on top of scale.

The Vary: Accept header matters if you cache images yourself, say in a Cloudflare Worker in front of the CDN. If that cache keys on the URL alone, the first visitor’s format is served to everyone after them.

How do you build a srcset from one Storefront API image?

Query url, width, height and altText once with no transform, then append the parameters per candidate width. This is what Hydrogen’s shopifyLoader does: it adds width, height and crop to the source URL and nothing else.

export type ShopifyImage = {
  url: string;
  width?: number | null;
  height?: number | null;
  altText?: string | null;
};

export type Crop = 'center' | 'top' | 'bottom' | 'left' | 'right';

type Options = {
  widths?: number[];
  aspectRatio?: number; // width / height, e.g. 1 for square, 4 / 5 for portrait
  crop?: Crop;
};

const DEFAULT_WIDTHS = [320, 480, 640, 800, 1080, 1280, 1600, 2048];

/** Append CDN size params, keeping any existing ones such as ?v= */
function sized(src: string, width: number, height?: number, crop?: Crop): string {
  const url = new URL(src);
  url.searchParams.set('width', String(width));
  if (height) {
    url.searchParams.set('height', String(height));
    url.searchParams.set('crop', crop ?? 'center');
  }
  return url.toString();
}

/**
 * Build src, srcset and width/height attributes from one Storefront API Image.
 * Pass the untransformed `url` field. Widths above what the source can supply
 * are dropped, because the CDN does not upscale.
 */
export function shopifyImageAttrs(image: ShopifyImage, options: Options = {}) {
  const { widths = DEFAULT_WIDTHS, aspectRatio, crop } = options;
  const srcW = image.width ?? Infinity;
  const srcH = image.height ?? Infinity;
  const maxW = aspectRatio ? Math.min(srcW, srcH * aspectRatio) : srcW;

  let usable = widths.filter((w) => w <= maxW);
  if (usable.length === 0 && Number.isFinite(maxW)) usable = [Math.floor(maxW)];
  if (usable.length === 0) usable = [widths[0]];

  const heightFor = (w: number) =>
    aspectRatio ? Math.round(w / aspectRatio) : undefined;

  const candidates = usable.map((w) => ({
    w,
    url: sized(image.url, w, heightFor(w), crop),
  }));
  const largest = candidates[candidates.length - 1];
  const intrinsicH = aspectRatio
    ? heightFor(largest.w)
    : image.width && image.height
      ? Math.round((largest.w * image.height) / image.width)
      : undefined;

  return {
    src: largest.url,
    srcset: candidates.map((c) => `${c.url} ${c.w}w`).join(', '),
    width: largest.w,
    height: intrinsicH,
    alt: image.altText ?? '',
  };
}

A cropped ratio has two limits, not one. A 1:2 crop of the 896 by 1200 source cannot be wider than 600 pixels, or the height it asks for runs past the original. The helper caps at Math.min(srcW, srcH * aspectRatio) for that reason. Using searchParams.set rather than string concatenation keeps a real store’s ?v= cache-busting parameter where it is.

We ran it on Node 24.12 (which strips the types itself) and type-checked it with TypeScript 5.9.3 in strict mode. On the 896 by 1200 boot image it produced candidates from 320 to 800, with height set to 1071. A square crop gave four candidates, the largest ?width=800&height=800&crop=center. We fetched three of the generated URLs and each came back at exactly the size its descriptor claimed. An uncapped ?width=1080, which the helper drops for this image, came back at 896 by 1200.

Feed width and height into the <img> as attributes so the browser reserves the space, and write a real sizes value. With sizes left out, the browser assumes the image fills the viewport and picks accordingly. Our post on what moves a Shopify Lighthouse score covers the layout-shift side, and the fetchpriority write-up covers the hero image.

Aliases or URL parameters?

Aliases are the documented route, and they keep the transform under the API’s contract. Each alias also adds a line to every query and fixes the widths on the server. URL parameters keep the query short and let the component decide, but none of the Storefront API reference pages we read document those parameter names. What supports them is Shopify’s own code and docs: Hydrogen’s loader builds them, the img_url Liquid docs document crop, and every transformed URL we got back used them.

We would use aliases for the one or two fixed sizes a page always needs, such as a cart thumbnail or an Open Graph image. For anything in a responsive layout we would query the plain url with width and height, build the srcset with a capped helper like the one above, and leave format to the CDN. On Hydrogen we would not write the helper at all, because Image already does this. We would still check that every image fragment selects width and height, since without them the cap silently switches off. And before relying on scale or preferredContentType, we would print what a real store returns for them, because mock.shop did not encode either.

Image handling is a routine part of the headless storefronts and theme rebuilds we take on in our Shopify development work.

Need this built properly?

Get in touch