Allyn Thomas
Oct 24, 2026
visibility 1
star star star star star
(0 votes)

Focal-point cropping with Cloudflare image resizing in Next.js

What is focal point cropping?

When creating content for an article on the web it's inevitable that article is going to be displayed on a wide variety of viewports. We create pages with layouts that respond to changes in viewports, whether that's opening up an article full screen on your mobile device, docking it to a specific region of a larger screen or in the ever growing popularity of foldable devices our layouts need to adapt to how the user wants to read them.

Images are the same, but often editors are provided a single file to upload. This can cause a problem when optimising that image for different screens. As CMS developers we want to support as many viewports as possible and provide different layouts, but often editors are left struggling to make their images work in these different scenarios.

To help editors keep the important parts of their images in view, regardless of how it lays out, we can allow them to set a focal point on the image. Combined with srcsets and sizes attributes, this allows us to keep the crop of the image for different sizes focused on the important parts of the image. We can take this even further and allow that same focal point to be set in the CSS to allow images cropped by flexible containers to maintain the area of interest.

This tried and tested method fell over on my first Next.js project with Optimizely and Cloudflare, where I encountered a limitation with their Cloudflare image loader that only allowed dynamic image resizing to the width and quality.

A focal point with nowhere to go

Our CMS editors were able to set a focal point on any image that needed it, and we wanted that point to drive both the crop Cloudflare serves and the crop the browser applies when the layout changes shape. Next.js's next/image makes the first half awkward: a custom loader receives exactly three values, src, width and quality, and nothing else.

Cloudflare's image resizing is powerful with many features that is able do everything we need to deliver responsive and optimised images. For our use case it's URL format accepts height, fit=crop and a gravity coordinate that picks where the crop is anchored. The gap is purely on the Next.js side: there is no supported channel to get a height or a focal point from a component into the loader.

The usual workarounds are unappealing. A global crop setting cannot vary per image. Query parameters on the source URL get passed through to the origin rather than read by the loader. Wrapping next/image and writing our own srcset throws away the responsive work the component does for free.

What we ended up with is small: one value, the focal point, used twice. The same pair of numbers becomes Cloudflare's gravity parameter on the edge and the browser's object-position in CSS. The sections below walk through why the loader contract is the obstacle, how we got the extra values past it, and why the CSS half matters as much as the edge half.

Why the loader cannot help on its own

The two APIs are a near-perfect match everywhere except the hand-off between them.

On the Cloudflare side, a transformed image is requested through a URL of the form https://<zone>/cdn-cgi/image/<options>/<source-image>. The options we care about are width, height, fit=crop and gravity. Gravity takes a decimal coordinate such as gravity=0.3x0.6, where 0 is the left or top edge and 1 is the right or bottom edge. Cloudflare's documentation describes the behaviour as positioning the output so that its gravity point sits at the same relative position as in the original image. Hold on to that sentence, because it is the whole trick.

On the Next.js side, a custom loader is wired up once in next.config.js:

module.exports = {
  images: {
    loader: "custom",
    loaderFile: "./src/cloudflareLoader.js",
    deviceSizes: [320, 480, 600, 905, 1024, 1240, 1440, 1920],
  },
};

The loader file must export one function with a fixed signature:

export default function myImageLoader({ src, width, quality }) {
  return `https://example.com/${src}?w=${width}&q=${quality || 75}`;
}

That is the obstacle. next/image calls the loader once for every candidate width in deviceSizes and imageSizes, building the srcset for you. The height prop you pass to the component is used for layout and aspect ratio but never reaches the loader. The focal point has no prop at all. Whatever we wanted Cloudflare to know about the crop had to travel inside one of src, width or quality.

width is the candidate width Next.js chose, so it is not ours to change. quality is a number. That leaves src, which is a free-form string that arrives at the loader exactly as the component received it.

A focal point is already an object-position

Our CMS stores the focal point as a string of two fractions separated by a pipe, for example 0.3|0.6. Both halves of the problem want exactly that pair of numbers, just spelled differently.

Consumer

Spelling of the same point

What it does with it

Cloudflare gravity

0.3x0.6

Positions the crop so the point at 30% across and 60% down the source lands at 30% across and 60% down the output

CSS object-position

30% 60%

Positions the image so the point at 30% across and 60% down the image lands at 30% across and 60% down the box

Cloudflare's gravity semantics and CSS percentage positioning both align a relative point in the image with the same relative point in the frame. That means the two crops compose. Cloudflare crops the pixels it sends to the aspect ratio the component asked for, keeping the subject at its relative position. If the rendered box then has a different aspect ratio, object-fit: cover with the same object-position crops again, and the subject stays put.

So the design became:

  1. Smuggle the height and focal point to the loader inside the src string, where Next.js cannot interfere with them.
  2. In the loader, turn the focal point into gravity=XxY, add fit=crop, and scale the height to match each candidate width so every srcset entry has the same aspect ratio.
  3. In the component, turn the same focal point into a CSS custom property and let object-position read it.

No custom srcset generation, no wrapper around next/image, and no second source of truth for where the subject is.

Implementation

There are three pieces: a tiny helper, the loader, and a FocalImage component that wraps next/image.

The helper

The CMS format is x|y. Everything else wants an array of two numbers.

export const ImageFocalPointDefault = "0.5|0.5";

export const gravityAsArray = (val: string): number[] => {
  const [x, y] = val.split("|");
  return [parseFloat(x), parseFloat(y)];
};

Encoding the crop into src

The component builds a src that starts with a quoted metadata block, then the real URL:

"height=720;originalWidth=1080;focal=0.3|0.6"https://cms.example.com/siteassets/office.jpeg

A double quote cannot appear unescaped in a URL, so it makes a safe delimiter. Next.js passes the string through untouched, and the loader can split on it. The originalWidth is the width the component was rendered with, so the loader can scale height for each candidate width and keep the aspect ratio constant across the srcset.

The component itself does the encoding and sets the CSS custom property. Trimmed of its fallback handling, it looks like this:

import Image, { ImageProps } from "next/image";
import { gravityAsArray } from "@/lib/images";
import "./focalImage.scss";

export type TypeFocalImage = ImageProps & {
  /** From the CMS as "x|y", each between 0 and 1 */
  imageFocalPoint: string;
  sizes: string;
};

export const FocalImage = (props: TypeFocalImage) => {
  const { imageFocalPoint = "", className = "", width, height, src, ...rest } = props;
  const classNames = [className, "focalImage"].join(" ");

  const cropHeight = height as number;
  if (!imageFocalPoint || typeof src !== "string" || !(cropHeight > 0)) {
    return <Image {...rest} src={src} className={classNames} width={width} height={height} />;
  }

  // 1. Smuggle the crop into src for the loader
  const meta = `height=${cropHeight};originalWidth=${width};focal=${imageFocalPoint}`;
  const encodedSrc = `"${meta}"${src}`;

  // 2. Turn the same focal point into an object-position
  const [x, y] = gravityAsArray(imageFocalPoint);
  const style = { "--image-focal-point": `${x * 100}% ${y * 100}%` } as React.CSSProperties;

  return (
    <Image
      {...rest}
      src={encodedSrc}
      style={style}
      className={classNames}
      width={width}
      height={cropHeight}
    />
  );
};

The stylesheet is three lines:

.focalImage {
  --image-focal-point: initial;
  object-fit: cover;
  object-position: var(--image-focal-point);
}

When the custom property is initial, object-position falls back to its default of 50% 50%, so images without a focal point behave as a plain centred cover.

Decoding in the loader

The loader checks for the quote, unpacks the metadata, and builds the Cloudflare URL:

import SiteConfig from "@/site-config";
import { gravityAsArray } from "@/lib/images";

export default function cloudflareLoader({ src, width, quality }) {
  let height = NaN;
  let gravity = "";
  let urlToProcess = src;

  // Metadata block present: "height=…;originalWidth=…;focal=…"<url>
  if (src.indexOf(`"`) > -1) {
    const parts = src.split(`"`);
    const pairs = parts[1].split(";").map((pair) => pair.split("="));
    const originalWidth = pairs[1][1];
    height = Math.round(parseFloat(pairs[0][1]) * (width / originalWidth));
    gravity = pairs[2][1];
    urlToProcess = parts[parts.length - 1];
  }

  const srcUrl = new URL(urlToProcess, SiteConfig.getPrimaryDomain());

  // Local development has no Cloudflare zone in front of it
  if (srcUrl.hostname === "localhost") {
    return srcUrl.href;
  }

  const params = [`width=${width}`, `quality=${quality || 75}`, "format=auto"];

  if (!isNaN(height)) {
    params.push(`height=${height}`);
    if (gravity.includes("|")) {
      const [x, y] = gravityAsArray(gravity);
      params.push(`gravity=${x}x${y}`, "fit=crop");
    }
  }

  const path = `/cdn-cgi/image/${params.join(",")}/${srcUrl.href}`;
  return new URL(path, SiteConfig.getCmsUrl()).href;
}

For a component rendered at 1080×720 with a focal point of 0.3|0.6, the 480px srcset candidate becomes:

https://cms.example.com/cdn-cgi/image/width=480,quality=75,format=auto,height=320,gravity=0.3x0.6,fit=crop/https://cms.example.com/siteassets/office.jpeg

Every candidate carries the same 3:2 ratio and the same gravity point, so the browser can pick any of them and the crop is identical.

Using it

Call sites look like an ordinary next/image, plus the focal point from the CMS:

<FocalImage
  src={image}
  alt={imageAlt}
  width={imageWidth}
  height={imageHeight}
  imageFocalPoint={imageFocalPoint}
  sizes="(min-width: 1440px) 720px, (min-width: 600px) calc(50vw - 48px), calc(100vw - 32px)"
  priority
/>

Where width and height come from decides what Cloudflare does. Pass the image's intrinsic dimensions and the edge crop is a no-op: Cloudflare only resizes, and CSS does all the cropping. Pass the dimensions the component wants, say a portrait 300×720 from a landscape original, and Cloudflare crops to that shape around the focal point before the bytes leave the edge.

Keeping the subject in frame between breakpoints

The edge crop solves bytes. The CSS crop solves layout, and it is the half that is easy to forget.

A hero image is rarely the same shape at every viewport. On a wide screen the box might be 21:9, on a tablet 16:9, and on a phone close to square. The bytes Cloudflare sends are one fixed ratio, whatever the component asked for. Something has to absorb the difference, and object-fit: cover is that something. It scales the image to fill the box and lets the overflow fall off the edges.

The default object-position of 50% 50% crops from the centre outwards. If the editor placed the subject at 0.3|0.6, a centred crop on a square phone box throws away the left-hand third of the picture, which is exactly where the subject is. Setting object-position: 30% 60% instead keeps the point at 30% across and 60% down the image pinned to the same point in the box, whatever shape the box becomes.

Because the two crops share one value and the same semantics, the result is consistent all the way down:

  • Cloudflare delivers a 3:2 image with the subject at 30% × 60%.
  • On desktop the box is also 3:2, so the browser shows the whole delivered image.
  • On a phone the box is 1:1, so the browser trims the sides, keeping the 30% column where it was.
  • On an ultrawide the box is 21:9, so the browser trims top and bottom, keeping the 60% row where it was.

No media queries and no second set of coordinates to keep in sync with the CMS. The editor moves one dot and both crops follow.

The cost of leaning on CSS alone is that the browser downloads pixels it then hides. A 3:2 image shown in a 1:1 box throws away a third of what was fetched. Where that matters, a <picture> element lets the edge do more of the cropping without changing anything for editors. Working mobile first, the <img> is the small square crop and each <source> adds a wider breakpoint, asking Cloudflare for that breakpoint's aspect ratio with the same gravity point. Next.js's getImageProps builds the srcSet and sizes for a source through the same loader the image uses:

import { getImageProps } from "next/image";

const focalSrc = (src: string, width: number, height: number, focal: string) =>
  `"height=${height};originalWidth=${width};focal=${focal}"${src}`;

const { props: wide } = getImageProps({
  src: focalSrc(src, 1728, 740, imageFocalPoint),
  width: 1728,
  height: 740,
  alt,
  sizes: "(min-width: 1920px) 1728px, calc(100vw - 192px)",
});

<picture>
  <source media="(min-width: 1440px)" srcSet={wide.srcSet} sizes={wide.sizes} />
  <FocalImage src={src} width={600} height={600} imageFocalPoint={imageFocalPoint} sizes="100vw" alt={alt} />
</picture>

The editor still uploads one image and places one focal point. The front end decides how many shapes to ask for, and object-fit: cover with the same object-position still handles every size between the breakpoints, so adding or removing a source later changes bytes, not the picture.

This is also why the component sets a CSS custom property rather than writing object-position inline. The stylesheet owns the rule and the behaviour; the component only supplies the value. Any wrapper that wants to override or animate the position can do so by setting --image-focal-point without touching the component.

Trade-offs and caveats

This is a workaround for a missing API, and it is worth being honest about the seams.

Height is rounded per candidate. Each srcset entry computes its own height from originalWidth, so a 3:2 image at 905px wide becomes 603px tall, not 603.33. Ratios drift by at most a pixel between candidates, which is invisible in practice but worth knowing if you compare URLs in tests.

Cloudflare will not upscale. fit=crop keeps the original size when the source is smaller than the requested box. Small originals in large components will arrive uncropped and object-fit: cover will do all the work, including stretching. That is the right behaviour, but it means the edge crop is a bandwidth optimisation, not a guarantee.

The loader is skipped locally. In development there is no Cloudflare zone in front of the origin, so the loader returns the raw URL. The CSS half still works, which is useful: you can check the focal behaviour on localhost even though the delivered image is uncropped.

Pass dimensions deliberately. If a component passes the intrinsic image size, Cloudflare resizes but does not crop, and CSS carries the whole burden. If it passes the component's own dimensions, Cloudflare crops to shape and the browser downloads fewer bytes. The second is better for LCP on hero images, the first is safer when the same image is reused in several shapes.

It only works for images in the solution. The Cloudflare zone sits in front of the CMS origin. Third-party images are not routed through /cdn-cgi/image/, so they get neither the resize nor the crop. The component falls through to a plain next/image in that case.

Takeaways

The Next.js loader contract is deliberately narrow, and src is the one channel the framework leaves alone. A delimiter that cannot appear in a URL makes it a safe place to carry the crop height and focal point to the loader, without wrapping next/image or giving up its srcset generation.

The more useful lesson is the one about the focal point. A CMS focal point, a Cloudflare gravity coordinate and a CSS object-position are three spellings of the same idea: keep this relative point of the image at this relative point of the frame. Once you notice that, you stop needing separate crop settings for the edge and the browser. One value from the editor drives the bytes that are sent and the pixels that are shown, and it keeps doing so at every breakpoint without any further configuration.

For editors that is as simple as it gets. Upload one image, place one focal point, and the same asset serves a hero, a card, a thumbnail and a carousel slide, each cropped around the subject at every breakpoint. There are no per-layout crops to maintain, and nothing to redo when a new layout appears.

Sources: Cloudflare transform via URL, Next.js Image component, MDN object-position.

Oct 24, 2026

Comments

error Please login to comment.
Latest blogs
Unit-Level Promotion Exclusions: Updated Behaviour and Developer Guide

Introduction Since Commerce 13, Optimizely Commerce Connect has supported two modes for promotion exclusion: order-level and unit-level. Order-leve...

Viet Anh Nguyen | Oct 23, 2026

Experimentation When the Agent Makes the Decision

When an agent enters the loop, an A/B test loses its obvious unit, its obvious variant and its obvious decision-maker. WebMCP, WebKit's objection, ...

Piotr | Oct 23, 2026 |

Virtual Opticon 2026: Where AI Can Improve DXP Delivery

Consider a campaign whose first draft takes ten minutes to generate but then waits five days for review. Faster drafting has helped one task. The...

Augusto Davalos | Oct 21, 2026