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.
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.
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 dev — localhost 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(/^\//, '')}`;
}
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.
Related
- Configuring Cloudflare Image Resizing URL parameters — the exact
/cdn-cgi/image/option grammar your loader builds - Next.js Image Component Optimization — the component and config this loader plugs into
- next/image with custom loader configurations — the signed-URL / HMAC variant of a custom loader
- Cloudflare Image Resizing and Polish — how the CDN performs resizing and format negotiation
- How to calculate optimal sizes attribute values — get the sizes prop right so the CDN generates the right widths