Next.js Image Custom Loader for a CDN

The built-in next/image optimizer runs sharp behind /_next/image. But if you already serve images through an image CDN β€” Cloudflare Image Resizing, Imgix, or Cloudinary β€” running that optimizer too means paying for two resizes and losing the CDN’s edge-native Accept negotiation. A custom loader fixes this: it makes <Image> emit CDN transform URLs instead of /_next/image paths, so the CDN owns resizing, re-encoding, and format selection while next/image keeps generating the responsive srcset and reserving layout space. This guide β€” part of Next.js Image Component Optimization within Framework & Build-Tool Media Integration β€” shows the exact loader function, next.config.js wiring, and verification steps.

This is distinct from a signed loader for a DAM system: if your CDN requires HMAC tokens or expiring signatures, follow next/image with custom loader configurations instead. Here the goal is the plain transform-URL case.

Prerequisite checklist

How a custom loader changes the request path

A loader is a pure function ({ src, width, quality }) => string. Next.js calls it once per width in the generated srcset, passing each candidate width from deviceSizes/imageSizes. Whatever string it returns becomes that srcset entry. The browser then picks a width using your sizes, and the request goes straight to the CDN β€” /_next/image is never involved.

Custom loader request path next/image calls the loader once per srcset width; the loader returns a CDN transform URL carrying width, quality, and format=auto; the browser selects a width via sizes and requests it directly from the CDN, which resizes, re-encodes per Accept, and caches at the edge. <Image> for each width calls loader() loader.ts builds transform URL width Β· quality format=auto srcset Browser picks width via sizes Accept Image CDN resize Β· re-encode AVIF/WebP Β· edge cache /_next/image is bypassed entirely β€” the CDN is the only optimizer

Exact solution

Step 1 β€” Write the loader

The loader must build a URL the CDN understands and, critically, forward both the width Next.js hands it and a quality (falling back to a default). The example targets Cloudflare Image Resizing, whose transform options live in a /cdn-cgi/image/<options>/<source> path segment.

// image-loader.ts
// A loader is called ONCE PER srcset width. `width` is the candidate width
// Next derived from deviceSizes/imageSizes; `quality` is the <Image quality> prop.
import type { ImageLoaderProps } from 'next/image';

const ZONE = 'https://cdn.example.com';

export default function cloudflareLoader({ src, width, quality }: ImageLoaderProps): string {
  // Build the Cloudflare option list. Each option is key=value, comma-joined.
  const params = [
    `width=${width}`,          // REQUIRED: without it every srcset entry is identical
    `quality=${quality || 75}`, // fall back to 75 β€” a missing quality yields the CDN default, not Next's
    'format=auto',             // let the CDN read Accept and emit AVIF/WebP/original itself
    'fit=scale-down',          // never upscale past the source's intrinsic width
  ].join(',');

  // src may be a root-relative path ("/hero.jpg") or absolute. Normalise to a
  // clean path the CDN can resolve against its origin.
  const normalized = src.startsWith('/') ? src.slice(1) : src;

  return `${ZONE}/cdn-cgi/image/${params}/${normalized}`;
}

For Imgix the same function returns https://acme.imgix.net/${src}?w=${width}&q=${quality||75}&auto=format; for Cloudinary it injects a w_,q_,f_auto transformation segment. The parameter names differ; the three pieces of information β€” width, quality, automatic format β€” are always the same. The exact Cloudflare option grammar is documented in configuring Cloudflare Image Resizing URL parameters.

Step 2 β€” Register the loader in next.config.js

// next.config.js
module.exports = {
  images: {
    // 'custom' disables /_next/image for ALL images; every <Image> now routes
    // through your loaderFile. Use the inline `loader` prop instead if you only
    // want it on some images.
    loader: 'custom',
    loaderFile: './image-loader.ts',
    // deviceSizes still governs which widths the loader is called with β€” trim
    // it to the widths your CDN should generate so you don't cache 8 variants
    // when 4 cover your breakpoints.
    deviceSizes: [640, 828, 1200, 1920],
  },
};

Step 3 β€” Use <Image> unchanged

import Image from 'next/image';

<Image
  src="/catalogue/chair-oak.jpg"   // passed to the loader as `src`
  alt="Oak dining chair, front view"
  width={800}
  height={800}
  quality={70}                     // arrives in the loader as `quality`
  sizes="(max-width: 640px) 100vw, 400px"
/>;

The component still reserves the 1:1 box (no CLS) and still emits srcset/sizes β€” but each srcset URL now points at the CDN.

Verification

1. Inspect the generated srcset

# Each width in deviceSizes should produce a DISTINCT CDN URL carrying that width.
# If every entry has the same width= value, the loader is ignoring the width arg.
curl -s https://localhost:3000/catalogue | \
  grep -o 'https://cdn.example.com/cdn-cgi/image[^" ]*' | sort -u

Expected: one URL per configured device size, each with a different width= value and all carrying format=auto.

2. Confirm format negotiation in the Network panel

Open DevTools β†’ Network β†’ filter β€œImg”. Reload and click the hero request. Under the emulated Chrome Accept header the CDN should return content-type: image/avif; switch the request’s Accept (or test in Safari 14) and it should fall to image/webp. If the response is always the source format, format=auto is missing or the CDN is not reading Accept.

# Same check from the CLI: an AVIF-capable Accept should yield image/avif.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
  'https://cdn.example.com/cdn-cgi/image/width=828,quality=70,format=auto/catalogue/chair-oak.jpg' \
  | grep -iE 'content-type|cf-resized|cache-control'

cf-resized: internal=ok/... confirms Cloudflare actually ran the transform rather than passing the origin through untouched.

Common mistakes

1. Returning the wrong URL shape

Anti-pattern: returning ${ZONE}/${src}?width=${width} when the CDN expects options in a path segment (Cloudflare) rather than a query string.

Effect: the CDN ignores the unknown parameter and serves the full-size original for every srcset entry. The browser downloads the largest file regardless of viewport, and LCP regresses versus the built-in optimizer.

Fix: match the CDN’s exact grammar β€” path options for Cloudflare (/cdn-cgi/image/width=…/), query params for Imgix (?w=…). Verify with the curl in step 1 that widths actually differ.

2. Forgetting the width or quality parameter

Anti-pattern: return ${ZONE}/cdn-cgi/image/format=auto/${src}``.

Effect: every srcset candidate is byte-for-byte identical because the CDN was never told which width to produce. The srcset becomes decorative; the browser cannot pick a smaller file on mobile.

Fix: always interpolate width=${width}. Because the loader is called once per width, dropping it collapses the whole responsive mechanism.

3. Mismatched deviceSizes

Anti-pattern: leaving deviceSizes at the default [640,750,828,1080,1200,1920,2048,3840] while the CDN’s plan bills per unique transform.

Effect: eight distinct transforms are generated and cached for every image β€” inflating CDN cost and cache churn for widths your layout never selects.

Fix: trim deviceSizes to the widths your breakpoints actually request, matching the values you use in sizes. Four well-chosen widths cover most layouts.

4. Running both optimizers

Anti-pattern: setting a custom loader but leaving image source URLs pointing back at /_next/image (e.g. via a proxy), so Next resizes and then the CDN resizes again.

Effect: double re-encoding, doubled latency, and blurry output from two lossy passes.

Fix: with loader: 'custom' the built-in optimizer is already disabled globally; make sure no upstream rewrite re-inserts /_next/image, and that the CDN fetches the source, not the optimized path.