Automating Next.js Image Optimization with Headless CMS

A headless CMS hands you raw asset URLs with opaque CDN paths, auth tokens, and cache-busting query strings that next/image can’t parse. The fix is to intercept media at the data-fetching layer and route it through a custom loader before it reaches the component. This keeps CMS storage separate from delivery while preserving locale routing, metadata, and performance budgets. The guide is part of Image Optimization Pipelines for CMS Assets.

There are two distinct ways to use next/image with a CMS, and mixing them up causes most problems. With the built-in optimizer, Next.js fetches the source from an allowed remote host and transforms it on your servers; remotePatterns controls which hosts are allowed. With a custom loader, Next.js does no transformation at all: the loader returns URLs for the CMS’s own image API, which resizes and converts, and remotePatterns is irrelevant. Choose one per image source.

The sections below trace one asset from CMS payload to optimized delivery:

Two paths through next/imageA CMS asset URL, after locale fallback resolution, is either sent through a custom loader that builds CMS image API URLs with width, quality and automatic format, or through the built-in optimizer, which only accepts hosts listed in remotePatterns; both produce srcset and sizes for the browser.CMS asset URLLocalefallbackCustom loaderCMS image APIBuilt-in optimizerremotePatternssrcset + sizesin HTMLloaderdefault
With a custom loader, the CMS transforms images; with the built-in optimizer, your servers do.

Validate Origins in next.config.js

Next.js requires you to whitelist remote image hosts, blocking open-redirect and hotlinking abuse. Declare your CMS endpoints in remotePatterns (the replacement for the legacy domains array), which matches on protocol, hostname, and path.

JavaScript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.yourcms.com',
        pathname: '/assets/**',
      },
    ],
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 86400,
  },
};

module.exports = nextConfig;

Listing formats makes the optimizer prefer AVIF and WebP during negotiation, cutting payload weight and improving Core Web Vitals with no editor involvement.

Build a Custom Loader for CMS URLs

The default next/image loader can’t read CMS-specific URL structures. A custom loader translates between your content API and the browser. The trap is query-parameter collision: platforms append version tokens (?v=1709234567) or tracking params, and blindly tacking on optimization directives produces malformed requests. Keep version tokens, since they are what makes an updated image get a new URL and a fresh cache entry, and drop only tracking parameters.

Parse, strip, and rebuild the URL with the native URL and URLSearchParams APIs for consistent behavior across server and edge runtimes.

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

export default function cmsImageLoader({ src, width, quality }: ImageLoaderProps): string {
  const cmsOrigin = process.env.NEXT_PUBLIC_CMS_ORIGIN || 'https://cdn.yourcms.com';
  const url = new URL(src, cmsOrigin);
  const params = new URLSearchParams(url.search);

  // Strip tracking parameters, but keep version tokens such as `v`: they make updated images get new URLs.
  params.delete('utm_source');
  params.delete('utm_medium');

  // Directives for the CMS image API (names vary by platform)
  params.set('w', width.toString());
  params.set('q', (quality || 75).toString());
  params.set('auto', 'format'); // let the CMS negotiate AVIF/WebP from the Accept header

  url.search = params.toString();
  return url.toString();
}

Register the loader globally in next.config.js or pass it per-component via <Image loader={cmsImageLoader} />. Loader signatures and hydration behavior are documented in the Next.js Image Component reference.

Resolve Localized Assets with Fallbacks

Multilingual deployments fragment the asset tree: editors upload region-specific imagery, and a missing variant produces a broken <img> and a layout shift mid-route-transition. Resolve locale fallbacks at the data-fetching boundary, before the URL reaches the component. This is one piece of a broader Image Optimization Pipelines for CMS Assets strategy.

TypeScript
// lib/locale-asset-resolver.ts
export function resolveLocalizedAsset(
  assets: Record<string, string>,
  locale: string,
  fallbackLocale = 'en'
): string {
  const localized = assets[locale];
  const fallback = assets[fallbackLocale];
  
  if (!localized && !fallback) {
    throw new Error(`No image asset found for locale: ${locale}`);
  }
  
  return localized || fallback;
}

Set sizes and priority for LCP

Editors don’t specify responsive breakpoints, so developers default to sizes="100vw" — which forces the browser to fetch the largest variant and wrecks Largest Contentful Paint (LCP). Compute sizes from your grid breakpoints instead, and reserve priority for the actual hero.

TSX
// components/HeroImage.tsx
import Image from 'next/image';
import cmsImageLoader from '@/lib/cms-image-loader';

interface HeroImageProps {
  src: string;
  alt: string;
}

export default function HeroImage({ src, alt }: HeroImageProps) {
  return (
    <Image
      loader={cmsImageLoader}
      src={src}
      alt={alt}
      priority
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      width={1200}
      height={800}
      fetchPriority="high"
    />
  );
}

Use priority only on the LCP candidate; it already sets high fetch priority and disables lazy loading, so the explicit fetchPriority is optional. For how browsers resolve srcset against sizes, see MDN on responsive images.

Keep ISR Caches in Sync

CMS platforms purge their CDN asynchronously, so Next.js can serve stale image metadata inside an ISR window. Close the gap with a webhook: on asset update, the CMS calls a revalidation route that forces a fresh fetch and rebuilds the optimized cache.

TypeScript
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';

export async function POST(request: NextRequest) {
  const { secret, path } = await request.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
  }

  try {
    revalidatePath(path);
    return NextResponse.json({ revalidated: true, now: Date.now() });
  } catch (err) {
    return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
  }
}

Asset updates now reach the edge within seconds, keeping the editorial state and production frontend in parity.

Built-in optimizer or custom loaderThe built-in Next.js optimizer compared with a custom loader to the CMS image API on who transforms, compute cost, format support, focal points and static export support.ConcernBuilt-in optimizerCustom loaderWho transformsyour serversCMS image CDNCompute costper variantnone for youFormatsAVIF, WebPas the CMS supportsFocal points, cropsmanualCMS parametersStatic exportnot availableworks
A custom loader is usually the better default when the CMS has a capable image API.

Building the Image Props from CMS Data

Most CMS image references include width, height, alt text and sometimes a focal point. Map them to next/image props in one helper, so every component passes the same, correct values and none forgets the dimensions that prevent layout shift. The helper is also the place to apply the locale fallback, choose the loader per source and derive a blur placeholder when the CMS provides one, such as a tiny base64 preview or a dominant colour.

TypeScript
// lib/image-props.ts
import type { ImageProps } from "next/image";
import cmsImageLoader from "@/lib/cms-image-loader";

interface CmsImage { url: string; width: number; height: number; alt?: string; lqip?: string }

export function imageProps(img: CmsImage, sizes: string, opts: { priority?: boolean } = {}): ImageProps {
  return {
    loader: cmsImageLoader,
    src: img.url,
    width: img.width,
    height: img.height,
    alt: img.alt ?? "",
    sizes,
    priority: opts.priority ?? false,
    ...(img.lqip ? { placeholder: "blur" as const, blurDataURL: img.lqip } : {}),
  };
}

// Usage: <Image {...imageProps(card.image, "(max-width: 768px) 100vw, 33vw")} />

Components then describe only what they know, the displayed size and whether the image is the LCP candidate, and the helper handles everything that comes from the CMS. When the CMS adds a field, such as a focal point, only the helper changes, and every image on the site benefits at once.

Gotchas & Edge Cases

  • remotePatterns with a custom loader. A loader bypasses the optimizer, so listing hosts has no effect; errors about unconfigured hosts mean the loader is not actually applied to that image.
  • Tokens in image URLs. Signed or private asset URLs with tokens must not be exposed in public HTML. Use public delivery URLs for published images.
  • Wrong sizes on cards. Grid cards with sizes="100vw" download images several times larger than displayed. Derive sizes from the grid.
  • Unoptimized SVGs. Pass unoptimized for SVGs, which the optimizer cannot improve.

Worked Example

A recipe site used the built-in optimizer for images from its CMS, which ran on the same servers as page rendering. During traffic spikes, image transformation competed with rendering for CPU, and optimizer cache misses after each deploy slowed pages further. Switching to a custom loader targeting the CMS’s image API, keeping version tokens, and deriving sizes from the grid moved all transformation to the CMS’s CDN. Server CPU during spikes dropped by about 40 percent and the image cache survived deploys, because it no longer lived on the application servers.

Application server CPU during a traffic spikePeak CPU utilization of application servers during comparable traffic spikes with the built-in image optimizer and with a custom loader to the CMS image API.Built-in optimizer88 % CPU at peakCustom loader52 % CPU at peak
Moving transformation to the CMS freed the servers for rendering.

Rollout Checklist

  • Choose the built-in optimizer or a custom loader per image source.
  • Keep version tokens in URLs and strip only tracking parameters.
  • Resolve locale variants before the URL reaches the component.
  • Derive sizes from layout breakpoints for every image.
  • Mark only the LCP image with priority.
  • Revalidate pages when an image’s metadata, not just its file, changes.

Frequently Asked Questions

Why do images still show old versions after an update?

Usually because the URL did not change. Make sure version tokens are preserved, so an updated asset produces a new URL.

Can we mix loaders?

Yes, per component. A global loader for CMS images and the default optimizer for local assets is a common setup.

Does the custom loader affect LCP?

It removes the optimizer hop on your servers, which often improves image TTFB, as long as the CMS image CDN is fast in your markets.

What quality setting should we use?

Start at 70 to 75 and compare with your own images. Screenshots and illustrations may need higher quality or lossless formats.

Should blur placeholders be used everywhere?

Only for large images where the wait is noticeable, such as heroes and galleries. For small images the placeholder adds bytes to the HTML without a visible benefit.

How do we debug which loader an image used?

Inspect the rendered srcset: URLs pointing at the CMS image host come from the custom loader, URLs starting with the framework’s image path come from the built-in optimizer.