next/image does two jobs, and they are easy to mistake for one. It writes the
markup — a srcset of candidate widths, sizes, lazy loading, the
dimensions that stop a layout jumping. And, unless you tell it otherwise, it resizes the images
too, on your own Next.js server. The first job is the useful one. The second can be moved
somewhere better without touching a single component.
What the default loader does
Every candidate in the srcset Next writes is a URL on your own application:
/_next/image?url=%2Fphotos%2Fhero.jpg&w=1080&q=75
The first time a browser asks for one, your server fetches the source, resizes it, re-encodes it, and keeps the result in a cache on its own disk. On one long-lived server that is a reasonable arrangement. It gets awkward in the shapes most deployments actually take:
- Several instances each build their own cache, so the same image is resized once per instance.
- Containers and serverless functions that restart lose the cache, and the work is done again.
- The resizing runs where your pages render. An image-heavy page on a cold cache spends the CPU your requests were waiting for.
A loader is one function
Next lets you replace the URL it writes for each candidate. A loader is given the source, the
width Next wants and, if the component set one, a quality — and returns a URL. Next calls it
once per width it puts in the srcset, and does nothing else with the image:
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './zawcloud-loader.js',
},
};
// zawcloud-loader.js
import { createLoader } from '@zawcloud/images/next';
export default createLoader({ baseUrl: 'https://a3f9c21b.zwcld.net' });
That is the whole change. Every <Image> in the application now points its
srcset at an image CDN, which fetches the original from wherever it already lives,
resizes it on the first request for each width, and caches it in front of your users rather
than on your application server. The component props, the lazy loading and the reserved space
all stay as they were.
Which widths Next asks for
The candidates come from two lists in next.config.js, deviceSizes and
imageSizes, and how many of them Next uses depends on one prop:
- With
sizes(orfill), Next offers the configured widths aswdescriptors, and the browser picks one from the layout widthsizesdescribes. - Without it, Next assumes a fixed-size image and offers only a 1x and a 2x candidate for the
widthyou gave.
The second is right for an avatar and wrong for a hero. An image that spans the page with no
sizes is offered at its width prop and double that, whatever the
screen. Set sizes on anything whose rendered width changes with the viewport; the
reasoning behind a good list is in the widths you actually need.
Every width is a separate rendition at the CDN: made once, then served from cache. A longer list costs a little more on the first request for each image and nothing afterwards, but it is still worth keeping short, for the reasons in cache keys multiply faster than you think. If your account allows only certain output widths, the two lists must be widths it allows, or those candidates are refused.
Quality and format
Next passes a quality to the loader only when the component sets one, and the loader sends it as it is. Otherwise the loader's own default applies, and failing that the edge's, which is 80. The more useful setting is to stop choosing a number at all:
export default createLoader({ baseUrl: 'https://a3f9c21b.zwcld.net', quality: 'auto' });
q=auto picks, per image, the lowest quality that still measures as the same picture,
rather than one number for a photograph and a screenshot alike. The format needs no setting:
the edge reads the browser's Accept header and sends AVIF (on the paid tiers),
then WebP, then the original's format, with the Vary header that keeps caches honest — the detail
letting the browser decide goes into.
Two things to check
-
Imported images.
import hero from './hero.jpg'gives a built path under/_next/static/media/. The CDN can fetch it only if the origin it reads from is your deployed site. If your originals live elsewhere, use string paths at that origin instead. -
unoptimizedstill means what it says. An image with it set bypasses the loader and is served exactly as it is, which is what you want for an SVG and rarely for anything else.
The setup, the options and the account settings that interact with it are in the Next.js guide. The same package does the same for Nuxt Image and Astro.