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

Which widths the loader is actually called with

The loader is a passive function; it never chooses widths. Next.js derives the candidate list before calling it, from two config arrays and from whether you passed sizes. Internally the component merges deviceSizes and imageSizes into one sorted list (call it allSizes), then:

Image props Widths handed to the loader srcset descriptor
sizes containing a vw term every allSizes entry ≥ deviceSizes[0] × (smallest vw ÷ 100) w
sizes with only absolute lengths (360px) every entry of allSizes w
no sizes, numeric width the first entry ≥ width, plus the first ≥ width × 2 x
no sizes, fill every entry of deviceSizes w

Two consequences fall straight out of that table. First, a sizes value of 100vw filters nothing (the smallest ratio is 1.0, and deviceSizes[0] × 1.0 is the smallest device size), so the loader runs for every configured width — eight transforms per image on the defaults. Second, a sizes value with no vw term at all pulls in imageSizes, whose default is [16, 32, 48, 64, 96, 128, 256, 384]; you get a 16-pixel-wide CDN transform cached forever alongside the useful ones. If your sizes is purely pixel-based, trim imageSizes to the icon widths you genuinely render.

The x-descriptor case is the one teams trip over. Omit sizes on an image with width={800} and Next emits only two candidates — the first list entry ≥ 800 and the first ≥ 1600 — as 1x/2x. The browser then ignores viewport width entirely and picks purely on device pixel ratio, which is right for a fixed-size logo and wrong for anything fluid. Getting this right is the same discipline as fixing CLS with next/image fill and sizes: the sizes string is load-bearing, not decorative.

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. What changes is where in the URL they live: Cloudflare puts them in a path segment before the source, Imgix in the query string after it, Cloudinary in a comma-joined transformation segment in the middle. The exact Cloudflare option grammar is documented in configuring Cloudflare Image Resizing URL parameters.

Where width, quality and format live in three CDN URL grammars The same transform expressed three ways. Cloudflare places width, quality and format=auto in a slash-cdn-cgi-slash-image path segment before the source path. Imgix appends w, q and auto=format as query parameters after the source path. Cloudinary joins w underscore, q underscore and f underscore auto with commas inside a transformation segment between the upload prefix and the source path. Same three parameters, three URL grammars width quality automatic format Cloudflare Image Resizing https://cdn.example.com /cdn-cgi/image/ width=828 ,quality=70 ,format=auto /catalogue/chair.jpg options in a path segment, comma-joined, placed before the source path Imgix https://acme.imgix.net /catalogue/chair.jpg ?w=828 &q=70 &auto=format options in the query string, after the source path — order is irrelevant Cloudinary https://res.cloudinary.com/acme /image/upload/ w_828, q_70, f_auto /catalogue/chair.jpg options in a transformation segment between the delivery prefix and the source path

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.

Step 4 — Handle the sources that must not be transformed

A single global loader sees every <Image> on the site, including ones the CDN should not touch: inline SVG logos it would rasterise, animated GIFs it would flatten to a still frame, and — during next devlocalhost paths the CDN cannot resolve at all. A production loader therefore branches before it builds a URL.

// image-loader.ts (production shape)
import type { ImageLoaderProps } from 'next/image';

const ZONE = 'https://cdn.example.com';
const DAM = 'https://assets.acme-dam.com';   // the only remote origin we allow
const PASSTHROUGH = /\.(svg|gif)$/i;         // vector + animated: transform would destroy them

export default function loader({ src, width, quality }: ImageLoaderProps): string {
  // 1. Vector and animated sources bypass the CDN entirely. Cloudflare rasterises
  //    SVG and returns only the first frame of a GIF unless anim=true is set.
  if (PASSTHROUGH.test(src)) return src;

  // 2. In dev the CDN cannot fetch http://localhost:3000/..., so every transform
  //    404s. Returning the raw path keeps `next dev` usable; production is unaffected.
  if (process.env.NODE_ENV === 'development') return src;

  const opts = `width=${width},quality=${quality || 75},format=auto,fit=scale-down`;

  // 3. Remote DAM assets: the absolute URL becomes the source operand. Allowlisting
  //    the origin here is REQUIRED — remotePatterns no longer applies (see below).
  if (src.startsWith(DAM)) return `${ZONE}/cdn-cgi/image/${opts}/${src}`;

  // 4. Everything else is a local /public asset. Strip the leading slash so the
  //    CDN resolves it against the zone root rather than producing a double slash.
  return `${ZONE}/cdn-cgi/image/${opts}/${src.replace(/^\//, '')}`;
}
Loader routing decision tree The loader tests each source in order. Vector or animated files return unchanged. In development the source returns unchanged because the CDN cannot reach localhost. A source on the allowlisted DAM origin is appended to the transform path as an absolute URL. Anything else is treated as a local public asset with its leading slash stripped. One loader, four routes — tested top to bottom loader({ src, width, quality }) src ends in .svg or .gif ? yes return src unchanged rasterising or reframing would destroy it no NODE_ENV is development ? yes return src unchanged the CDN cannot fetch localhost:3000 no src starts with the DAM origin ? yes /cdn-cgi/image/<opts>/ + absolute URL allowlisted origin — nothing else is fetched no otherwise: local /public asset /cdn-cgi/image/<opts>/ + path leading slash stripped to avoid a double slash

Warning: the dev-mode branch means the URLs you see in next dev are not the URLs production emits. Always run the verification below against a next build && next start or a preview deploy, never against the dev server.

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.

5. Assuming remotePatterns still guards remote sources

Anti-pattern: keeping an images.remotePatterns allowlist and passing a CMS-supplied absolute URL straight into the loader’s template string.

Effect: remotePatterns and the older domains option are enforced by /_next/image only. Once loader: 'custom' is set, that validation never runs — Next.js hands your loader whatever src the component received. A CMS field an editor (or an attacker) controls becomes the source operand of your CDN’s fetcher, which will happily proxy and cache arbitrary third-party bytes under your hostname. That is an open image proxy plus a cache-pollution vector, and the bandwidth is billed to you.

Fix: allowlist in the loader itself, as in Step 4 — compare new URL(src).origin against a fixed set and fall back to the raw src (or a placeholder) on a miss. Pair it with the CDN’s own origin restrictions where they exist; Cloudflare, for instance, only resizes same-zone sources unless you explicitly permit external fetches.

FAQ

Does a custom loader still work with output: 'export'?

Yes, and it is one of the main reasons to adopt one. A fully static export has no Node runtime, so /_next/image cannot exist; the built-in optimizer is unavailable and next build errors unless you set images.unoptimized: true, which throws away srcset entirely. A custom loader sidesteps both: URL construction happens during render, so the exported HTML carries a complete responsive srcset pointing at the CDN.

Do remotePatterns and domains still protect remote images?

No — see mistake 5. Both settings configure the built-in optimizer’s fetch allowlist. With a custom loader Next.js performs no host validation at all, so origin checks are your loader’s responsibility.

Can I still use placeholder="blur"?

For static imports, yes: the blur data URL is generated at build time by the image import plugin, independently of which loader resolves the runtime URL. For a string src you must pass blurDataURL yourself — Next.js throws at render if placeholder="blur" is set without one. Many CDNs can produce the placeholder for you (a 16 px-wide, heavily quantised variant fetched at build and inlined as a data URI).

How do I keep /_next/image for a few images?

Leave images.loader at its default and pass the function to individual elements via the per-image loader prop: <Image loader={cloudflareLoader} src="…" />. The prop overrides the global configuration for that element only, so the rest of the site keeps using the built-in optimizer. This is the migration path — move one route at a time, compare bytes and LCP, then flip the global config.

Should the CDN response be cached as immutable?

Only if the transform URL is content-addressed. A /cdn-cgi/image/width=828,…/hero.jpg URL is not immutable: replace hero.jpg at the origin and the same URL must return new bytes. Give transform URLs a bounded max-age with stale-while-revalidate, and reserve immutable for hashed filenames — the reasoning is worked through in best practices for setting max-age on CDN media assets.