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.comFor 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.comThese 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:
- Confirm that
srcsetcontains multiple Keenpix URLs with differentwvalues. - Resize the viewport and check which candidate the browser selects.
- Inspect
Content-Type,Vary, andCache-Controlon the image response. - 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.
