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.


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.


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.


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.