Skip to content
All postsNext.js responsive image widths flowing through a Keenpix loader

How to optimize Next.js images with a custom CDN loader

Connect next/image to Keenpix with a custom loader, responsive sizes, format negotiation, and an origin allowlist you can verify in the browser.

nextjsresponsive-imagesimplementation

next/image does not need to use the Next.js optimization endpoint. A custom loader keeps the component and its responsive srcset, but changes the URL behind each candidate. That is the useful seam for an external image CDN.

The setup below sends each width selected by Next.js to Keenpix. It does not hide the source URL, move your originals, or put a signing secret in browser code.

Add one loader for the application

Create keenpix-loader.js at the project root:

'use client'

export default function keenpixLoader({ src, width, quality }) {
  const baseUrl = process.env.NEXT_PUBLIC_KEENPIX_BASE_URL
  const project = process.env.NEXT_PUBLIC_KEENPIX_PROJECT
  const origin = process.env.NEXT_PUBLIC_IMAGE_ORIGIN

  if (!baseUrl || !origin) {
    throw new Error('Keenpix image environment variables are missing')
  }

  const source = src.startsWith('http') ? src : new URL(src, origin).href
  const params = new URLSearchParams({
    fmt: 'auto',
    w: String(width),
  })

  if (project) {
    params.set('project', project)
  }

  if (quality) {
    params.set('q', String(quality))
  }

  return `${baseUrl}/img/${encodeURIComponent(source)}?${params.toString()}`
}

For managed Keenpix, put the project id in the canonical delivery path:

NEXT_PUBLIC_KEENPIX_BASE_URL=https://cdn.keenpix.com/p/your-project-id
NEXT_PUBLIC_IMAGE_ORIGIN=https://assets.example.com

For self-hosted Keenpix, the base URL is your image host and the project remains a query parameter:

NEXT_PUBLIC_KEENPIX_BASE_URL=https://images.example.com
NEXT_PUBLIC_KEENPIX_PROJECT=your-project-id
NEXT_PUBLIC_IMAGE_ORIGIN=https://assets.example.com

These public variables describe routing, not credentials.

Add assets.example.com to the project's source-host allowlist. Keenpix rejects a source host that is not on that list.

Tell Next.js to use it

Configure the loader once:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './keenpix-loader.js',
  },
}

The file path is relative to the project root. Next.js expects its default export to return a URL string.

Your components still use Image:

import Image from 'next/image'

export function ProductHero() {
  return (
    <Image
      alt="Canvas backpack in olive green"
      height={1200}
      sizes="(max-width: 768px) 100vw, 50vw"
      src="/products/backpack.jpg"
      width={1600}
    />
  )
}

sizes deserves attention. Next.js uses it when building the width candidates, and the browser uses it when choosing one of those candidates. If a responsive image has no useful sizes value, the browser may assume a much wider layout and download more pixels than the slot needs.

What fmt=auto changes

The loader asks Keenpix for fmt=auto. Keenpix checks the request's Accept header and selects AVIF, WebP, or JPEG. The response varies on that header, so every cache in front of the image service must respect Vary: Accept.

The AVIF and WebP production guide shows how to test those variants without trusting the response header alone.

Width and quality are part of the transformation URL. A 640-pixel candidate and a 1200-pixel candidate are separate cache variants. This is expected. Random query parameters are not: they create needless cache keys and should be rejected or covered by a signature when abuse is a concern.

Check the result in a browser

Do not stop when the image appears. Inspect the rendered <img> and the selected image request:

  1. Confirm that srcset contains multiple Keenpix URLs with different w values.
  2. Resize the viewport and check which candidate the browser selects.
  3. Inspect Content-Type, Vary, and Cache-Control on the image response.
  4. Verify that an unapproved source host fails instead of being fetched.

Measure transferred bytes with your own source image and settings. A single percentage copied from another image says nothing reliable about your catalog.

Signed URLs need a server boundary

The global loader runs in browser-facing code. Do not place a Keenpix signing secret in it. If the project requires signed transform URLs, generate them on a server you control or route image requests through a server-side signing endpoint. The public project id and the signing secret are not interchangeable.

For many public sites, the source-host allowlist is enough. Add signatures when third parties can create expensive variants or append parameters that manufacture cache misses. A signature does not bind a valid image URL to one referring site, and it does not prevent someone from copying and replaying that URL.

When this setup is the wrong fit

Keep the built-in Next.js optimizer when it already meets your deployment and cost requirements. A custom CDN adds another service boundary, cache policy, and failure mode. It earns that complexity when you need one image URL grammar across frameworks, want a separately managed delivery layer, or plan to move the same workload between managed and self-hosted Keenpix.

Sources and verification

This guide was checked on August 15, 2026 against the current Next.js Image documentation, the Next.js custom loader configuration, and the Keenpix Next.js guide. The example describes request behavior; it is not a performance benchmark.

Optimized images, minus the surprise bill.

One published price on managed image delivery. Or self-host the open-source engine, free.