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.
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.
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