next/image with Custom Loader Configurations

The Next.js <Image> component ships with a built-in optimization pipeline that rewrites every image URL through /_next/image. For teams routing assets through an enterprise Digital Asset Management (DAM) system or an external CDN that enforces HMAC signatures or time-limited tokens, this pipeline strips the authentication parameters before they reach the origin — producing 403 Forbidden responses, forcing browser fallback to unoptimized originals, and collapsing Largest Contentful Paint (LCP) by 300–600 ms per page load. This guide, part of Responsive Video Delivery in Next.js and React, shows exactly how to implement a custom loader to route Next.js responsive images through any signed CDN while preserving the full srcset pipeline.

Prerequisite Checklist

Before wiring up a custom loader, confirm each condition:


Architecture: How next/image Loader Hooks Work

The diagram below shows the request path with and without a custom loader.

next/image request flow: default loader vs custom loader Two parallel lanes. Default loader rewrites URL through /_next/image, strips signature params, and hits origin. Custom loader passes the signed URL directly to the CDN. Default loader Custom loader Browser requests /page next/image rewrites → /_next/image Signature stripped ?sig= removed Origin: 403 Forbidden Browser requests /page customLoader({ src, width, quality }) Signed CDN URL returned sig= preserved CDN: 200 OK — image served

The loader function is a pure function called at render time — once per srcset width variant. It must be synchronous (no await) and side-effect-free, because Next.js calls it on both the server (RSC / SSG) and the client.


Step 1 — Author the Loader Function

Create a TypeScript module. The function signature is fixed by Next.js: src, width, and optional quality.

// lib/custom-image-loader.ts
import type { ImageLoaderProps } from 'next/image';

export const customLoader = ({ src, width, quality }: ImageLoaderProps): string => {
  // Use URL constructor to safely append params without breaking existing query strings.
  // Direct string concatenation would corrupt URLs that already contain `?` or `&`.
  const url = new URL(src);

  // `w` and `q` are the transformation params your CDN expects.
  // Adjust key names to match your DAM API (e.g. Cloudinary uses `w_`, Imgix uses `w`).
  url.searchParams.set('w', String(width));
  url.searchParams.set('q', String(quality ?? 75)); // 75 is Next.js default quality

  // Do NOT call url.searchParams.delete('sig') — existing signature params must survive.
  // Next.js generates srcset entries for every width in the `deviceSizes` / `imageSizes`
  // arrays, so this function runs ~10 times per <Image> render.
  return url.toString();
};

Warning: Do not import server-only modules (e.g. crypto, fs) directly in a loader file if you also use the loader on the client side. Isolate HMAC signing behind a server action or API route, and pass pre-signed src values to <Image>.


Step 2 — Register the Loader in next.config.js

Use loaderFile, not pathpath is for the deprecated loader: 'imgix' style and does nothing for custom loaders.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    loader: 'custom',
    // loaderFile path is relative to the project root, not the config file.
    // Must export a default or named function matching ImageLoaderProps.
    loaderFile: './lib/custom-image-loader.ts',

    // remotePatterns replaces the deprecated `domains` array (removed in Next.js 14).
    // Each entry must exactly match protocol + hostname. Wildcards use `**` glob syntax.
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'assets.your-dam.com',  // exact CNAME — no trailing slash
        // pathname: '/images/**',         // optional path scope
      },
    ],

    // deviceSizes drives the srcset widths passed to your loader.
    // Default: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
    // Trim to realistic breakpoints to reduce loader call count and CDN variants.
    deviceSizes: [640, 828, 1080, 1200, 1920],

    // imageSizes covers layout="fixed" and layout="intrinsic" small images.
    imageSizes: [16, 32, 64, 128, 256],
  },
};

module.exports = nextConfig;

Tradeoff: A broad deviceSizes array increases srcset entries, which means more signed URLs generated per render and more unique cache keys on your CDN. On a DAM with time-expiring signatures, this can cause cache thrashing. Trim deviceSizes to the widths your design system actually uses.

The fan-out is the part teams underestimate. Next.js does not call your loader once per <Image> — it calls it once per candidate width, synchronously, during render, and concatenates the results into a single srcset attribute. Every one of those URLs becomes a distinct cache key at the edge, and every one of them will be signed if your signing runs inside the loader. The browser then discards all but one, chosen from the sizes attribute and the device pixel ratio before a single byte is fetched.

How one Image render becomes five signed URLs and one fetch One next/image render fans out through the custom loader once per deviceSizes entry, producing five signed CDN URLs in the srcset attribute. The browser then evaluates the sizes attribute and device pixel ratio and fetches exactly one of them; the other four are never requested but still occupy CDN cache keys. One render → five signed URLs → one fetch The loader is a synchronous, pure function called once per candidate width customLoader() invoked once per deviceSizes entry <Image> renders deviceSizes: [640, 828, 1080, 1200, 1920] hero.jpg?w=640&q=75&sig=… hero.jpg?w=828&q=75&sig=… hero.jpg?w=1080&q=75&sig=… hero.jpg?w=1200&q=75&sig=… hero.jpg?w=1920&q=75&sig=… Browser picks one sizes + DPR decide other four never fetched Unfetched candidates still cost you: each is a distinct edge cache key and, if you sign inside the loader, a distinct signature

Warning: because the loader runs during render on both the server and the client, anything non-deterministic inside it produces a different srcset on each side and React logs a hydration mismatch. The most common culprits are Date.now(), Math.random() nonces, and reading a mutable module-level counter. Keep the function referentially transparent: same src, width, and quality in, same string out, forever.


Step 3 — Apply the Loader at Component Level

Pass the loader prop directly for a scoped override (e.g. one hero image with different signing logic), or omit it to fall through to the global loaderFile.

// components/HeroImage.tsx
import Image from 'next/image';
import { customLoader } from '@/lib/custom-image-loader';

interface HeroImageProps {
  src: string;   // pre-signed CDN URL, generated server-side
  alt: string;
}

export default function HeroImage({ src, alt }: HeroImageProps) {
  return (
    <Image
      loader={customLoader}
      src={src}
      alt={alt}
      width={1200}   // layout dimension in px — used for aspect ratio, not necessarily rendered size
      height={600}
      // `priority` injects <link rel="preload"> and sets fetchpriority="high" on the <img>.
      // Only use on the above-the-fold LCP element — applying it to multiple images
      // triggers fetchpriority starvation and delays CSS / font resources.
      priority
      // `sizes` is the responsive hint passed to the browser, independent of srcset.
      // Match it to your CSS grid breakpoints to avoid the browser over-fetching.
      sizes="(max-width: 768px) 100vw, (max-width: 1280px) 80vw, 1200px"
      // `onError` prevents layout collapse if the CDN returns a non-200 response.
      onError={(e) => {
        e.currentTarget.src = '/static/fallback-hero.jpg';
        // Remove srcset so the browser does not retry signed variants after signature expiry.
        e.currentTarget.removeAttribute('srcset');
      }}
    />
  );
}

For native lazy loading on below-the-fold images, simply omit the priority prop — Next.js defaults to loading="lazy" when priority is absent.


Configuration Reference

Every key below changes what your loader receives or what Next.js does with the string it returns. The defaults are the ones that surprise people in production.

Key or prop Default What it actually controls
images.loader 'default' 'custom' disables /_next/image entirely; the built-in optimizer stops running, including its format negotiation
images.loaderFile none Project-root-relative path to a module with a default export matching ImageLoaderProps; required whenever loader is 'custom'
images.deviceSizes [640, 750, 828, 1080, 1200, 1920, 2048, 3840] The candidate widths for sizes-driven images — one loader call and one srcset entry per value
images.imageSizes [16, 32, 48, 64, 96, 128, 256, 384] Additional widths used only when sizes is absent and the image has fixed dimensions; concatenated with deviceSizes
images.remotePatterns [] Allowlist matched against the literal hostname in src; DNS aliases and CNAMEs are not resolved
images.qualities [75] (Next 16+) Allowlist of quality values; a value outside it is rejected at build time rather than silently clamped
images.minimumCacheTTL 60 Only affects the built-in optimizer’s cache — inert once loader: 'custom' is set, so your CDN headers become the only TTL
loader prop falls back to loaderFile Per-component override; the prop wins over global config and lets one image use different signing logic
sizes prop none A media-query list the browser resolves before fetching; without it a fill image assumes 100vw and pulls the largest candidate
quality prop 75 Passed through to your loader as the third field; arrives as undefined, not 75, when the prop is omitted
unoptimized prop false Bypasses the loader completely and emits the raw src; the correct escape hatch for SVGs and pre-sized assets

Tradeoff: setting unoptimized on a per-image basis is cleaner than adding conditional branches inside the loader, because the loader runs on every render of every image and a branch there is a cost paid site-wide. Reserve loader logic for URL construction and push policy decisions up to the call site.

Verification Steps

After deploying or running npm run dev, verify each of the following before pushing to production.

1. Check that all srcset variants return 200 in the Network panel

Open DevTools → Network → filter by Img type. Click the hero image request and expand the response headers. Confirm status: 200 for every width variant listed in the srcset attribute. If any variant returns 403, your signature is being dropped or the remotePatterns hostname does not match.

2. Inspect URL signature preservation with curl

# Grab the srcset from the rendered HTML and test one variant directly
curl -sI "https://assets.your-dam.com/hero.jpg?w=1080&q=75&sig=<your-token>" \
  | grep -E "HTTP|content-type|cache-control"
# Expected:
# HTTP/2 200
# content-type: image/webp   (or image/avif if your CDN negotiates format)
# cache-control: public, max-age=31536000, immutable

3. Confirm priority prop attributes in the rendered HTML

# Build and inspect the output HTML
npm run build && npm start
curl -s http://localhost:3000 | grep -A3 'fetchpriority'
# Expected: fetchpriority="high" loading="eager" on the hero <img>

4. Measure LCP improvement with Lighthouse

Run Lighthouse in Chrome DevTools (mobile preset) before and after. Target LCP under 2.5 s. A correctly signed CDN origin with immutable Cache-Control headers on subsequent visits drives LCP into the sub-second range for cached assets.


Common Mistakes and Fixes

1. Using path instead of loaderFile

The path config key does nothing for custom loaders in Next.js 13+. Next.js silently falls back to the default internal optimizer, re-introducing URL rewriting. Always use loaderFile.

// Wrong — has no effect for custom loaders
images: { loader: 'custom', path: './lib/loader.ts' }

// Correct
images: { loader: 'custom', loaderFile: './lib/custom-image-loader.ts' }

2. Setting loader: 'custom' without loaderFile

Next.js throws a hard configuration error at startup: Error: loader is set to 'custom', but loaderFile is missing in 'next.config.js'. Always pair the two keys.

3. Mutating or re-signing the URL on every render

If your loader reads Date.now() to generate an expiry timestamp, the signature changes on every server render and every client hydration. This causes hydration mismatches (Text content does not match server-rendered HTML) and defeats CDN caching. Generate signed src values once, in a server action or getServerSideProps, and pass them as stable props.

4. Applying priority to more than one image per page

The priority prop sets fetchpriority="high" on the image request. Browsers cap the number of high-priority requests in flight. Marking three hero images priority starves CSS and font fetches, worsening Time to First Byte (TTFB) and delaying LCP for the image that actually needs it. Reserve priority for the single above-the-fold LCP element.

5. remotePatterns hostname mismatch

If your DAM is behind a CNAME (e.g. media.acme.comassets.dam-vendor.com), you must list the exact hostname that appears in the src prop, not the upstream hostname. Next.js matches the hostname field against the URL you pass — it does not follow DNS aliases.

6. Forgetting that quality can be undefined

Next.js only passes quality when the <Image> element sets it. A loader that does String(quality) without a fallback emits q=undefined, which most CDNs treat as an invalid parameter — Imgix and Cloudflare ignore it, Cloudinary returns 400, and a signing proxy that includes the query string in its HMAC will produce a signature that no longer matches the URL you meant to sign. Always default it (quality ?? 75) before serialising.

7. Signing the URL and the transformation params separately

If your CDN’s HMAC covers the full query string, appending w and q after signing invalidates the signature. Order matters: build the complete parameter set first, then sign, then serialise. Since the loader is client-visible you cannot sign there — the correct split is to have the server mint a signed base URL whose HMAC already covers a wildcard or a pre-agreed parameter set, and let the loader only fill in values that the signature scheme explicitly excludes.

When a variant fails, work the failure from the URL outward rather than guessing at config. The rendered srcset contains the exact string your loader produced, and each branch below is decided by looking at that string or its response status.

Triage tree for a failing custom-loader image request A three-question decision tree. If the URL still contains /_next/image the loader was never registered. If the sig parameter is missing the loader rebuilt the URL. If the CDN answers 403 the signature expired or was minted per render. Otherwise the signing is correct and the problem lies in remotePatterns or caching. Triaging a failing loader URL Read the answer off the rendered srcset before touching next.config.js URL contains /_next/image ? Is the sig= param still present? Does the CDN answer 403? Signing is correct — look further out check remotePatterns hostname and CDN Cache-Control Loader was never registered set loader:'custom' AND loaderFile — not path The loader rebuilt the URL use new URL(src); set only w and q; delete nothing Signature expired or per-render mint it once server-side, pass a stable src prop yes no yes no yes no

Work the tree top-down and it converges in three DevTools glances. The one branch worth a second look is the last: a 200 OK that still yields a soft failure usually means the CDN negotiated a format your Accept header did not offer, or that a Vary header is missing so the edge is serving a WebP body to a client that asked for AVIF. That belongs to caching rather than to the loader, and is covered in the Cache-Control reference for media assets.


Performance Impact Reference

Metric Baseline (no loader) Custom loader + signed CDN
LCP (mobile 4G) 3.2 s — 403 fallback chain 1.6 – 2.1 s
TTFB (cached CDN edge) 350 ms < 80 ms
CLS 0.05 (no explicit dimensions) < 0.01 (explicit width/height)
Wasted bytes (unoptimized fallback) ~800 KB JPEG 80 – 120 KB WebP/AVIF

Values assume a single-origin DAM with a CDN PoP within 30 ms of the user. Real numbers vary by CDN topology and origin response time.