What the Optimizer Does: From Your Tag to `/_next/image?url=…` and Back
A product page ships <img src="/hero.jpg" /> as a 2.4 MB JPEG to every visitor, phone included. Someone opens Lighthouse, files a ticket, and the fix looks like a one-line swap to next/image. The swap is trivial — what matters is the HTML the component now emits and what your server does the first time it serves one of those URLs.
<img
src="/_next/image?url=%2Fhero.jpg&w=1080&q=75"
srcset="/_next/image?url=%2Fhero.jpg&w=640&q=75 640w,
/_next/image?url=%2Fhero.jpg&w=1080&q=75 1080w,
/_next/image?url=%2Fhero.jpg&w=1920&q=75 1920w"
sizes="100vw"
loading="lazy"
width="2400" height="1350"
alt="Team collaborating around a laptop"
/>
Every candidate is an endpoint on your own origin. On the first request for a variant, Next fetches the source image, resizes it to the requested width, negotiates a format from the request's Accept header, and writes the result into a disk cache under <distDir>/cache/images. The second request for that variant never touches the source again.
The defaults are where the wins come from:
| Setting | Default | Consequence |
|---|---|---|
formats |
['image/webp'] |
a 2.4 MB JPEG typically lands around 100–200 KB as WebP |
deviceSizes |
[640, 750, 828, 1080, 1200, 1920, 2048, 3840] |
the browser picks one of these widths — no other width is ever generated |
imageSizes |
[32, 48, 64, 96, 128, 256, 384] |
small thumbnails draw from this list instead |
quality |
75 |
Next 16 also pins the API to qualities: [75]; a direct request with q=80 answers 400 |
minimumCacheTTL |
14400 |
variants stay cached at least 4 hours, longer if the source's Cache-Control allows |
Three defaults do most of the work: below-the-fold images load lazily, the browser downloads exactly one width from the srcset, and the bytes arrive as WebP instead of your original file. AVIF is opt-in (formats: ['image/avif', 'image/webp']): roughly 20% smaller than WebP but about 50% slower to encode, so the first hit per variant gets slower and each format is cached separately.
A concrete shape of the win: a 2400 × 1350 JPEG at 2.4 MB serves at roughly 180 KB as the 1920w WebP and around 60 KB as the 640w variant a phone actually selects — and because variants are cached server-side, the resize is paid once per (width, format) pair, not once per visitor. When the optimizer itself becomes the bottleneck — a self-hosted deployment under heavy image traffic — replace the pipeline without touching components: images.loaderFile exports a function ({ src, width, quality }) => string returning your CDN's own resize URL, or images.path renames the /_next/image endpoint so a proxy can front it. The component contract stays identical; only who performs the resize changes.
Static imports change the caching story:
import hero from './hero.jpg' // URL now carries a content hash
<Image src={hero} alt="Team collaborating around a laptop" />
A hashed URL can ship with long-lived immutable headers, and for jpg/png/webp/avif sources Next generates the blurDataURL that powers placeholder="blur" for free.
Two limits catch teams out. First, the optimizer does not forward request headers when it fetches the source, so an image behind cookie- or token-based authentication fails to optimize — pass unoptimized for those. Second, SVG and animated GIFs are served as-is: SVG is script-capable and resizes losslessly, so it is never optimized by default (a src ending in .svg gets unoptimized automatically; dangerouslyAllowSVG is the opt-out you should think twice about).
onLoad, onError, and any other function prop require a Client Component, since a function cannot cross the server boundary — everything else works from Server Components. That server/browser split is the same one you already navigated in End-to-End Tests with Playwright.
The URL shape is only half the contract. width, height, and sizes decide which variant gets downloaded and whether anything on the page moves while it loads — and getting them wrong makes the pipeline irrelevant.
Reserving Space and Choosing a Width: `width`/`height`, `fill`, and the Missing `sizes`
Everyone has watched a page shove downward as images arrive, dropping the paragraph they were reading below the fold. That jump is Cumulative Layout Shift, and an image with no declared dimensions is its most common cause. next/image forces the issue: you pass width and height, or you use fill, or you statically import.
The pair is not the rendered size. It is the intrinsic size, which lets the browser reserve the correct aspect-ratio box before a single image byte arrives:
// 2400×1350 source, rendered 800px wide by CSS — no shift on load
<Image
src="/hero.jpg"
width={2400}
height={1350}
style={{ width: '100%', height: 'auto' }}
alt="Team collaborating around a laptop"
/>
When the ratio is unknowable — user uploads, CMS crops — use fill. The image becomes position: absolute inside its parent, so the parent must be positioned (relative, fixed, or absolute), and objectFit controls the crop:
<div className="relative aspect-video">
<Image
fill
src={post.cover}
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
alt={post.title}
/>
</div>
Now the trap. sizes tells the browser how wide the image will actually render, and it changes which srcset Next generates in the first place:
| You write | srcset Next generates | Browser assumes |
|---|---|---|
width/height, no sizes |
a short 1x/2x list (fixed-size image) | the declared width |
fill, no sizes |
deviceSizes only (640…3840) |
100vw — full viewport |
fill + sizes="(max-width: 768px) 100vw, 50vw" |
the full list from both size arrays | your CSS |
Omit sizes on a fill image and a 300px card on a 390×844 phone downloads a 640w or 750w variant — on a retina laptop, the 1920w one. The pipeline did its job; the selection heuristic was wrong. The rule: every fill image and every CSS-resizable image gets a sizes that mirrors your layout — 100vw for full-bleed heroes, 50vw for two columns, (max-width: 768px) 100vw, 33vw for a three-column grid. (The browser's fallback when sizes is missing is 100vw, which is why the wrong guess is always "too big", never "too small".)
The second lever is load order. By default the component emits loading="lazy", correct below the fold and wrong for the hero: a lazy hero waits until the layout positions it before fetching, pushing Largest Contentful Paint out by tens to hundreds of milliseconds. Mark the single above-the-fold candidate:
<Image
src="/hero.jpg"
width={2400}
height={1350}
alt="Team collaborating around a laptop"
preload
loading="eager"
fetchPriority="high"
/>
preload (the older priority prop is deprecated as of Next.js 16) injects a <link rel="preload"> into the document head so the fetch starts during HTML parse, before the parser reaches the image. Use it on one image — the one you can defend as the LCP element — and know the official guidance: for most pages loading="eager" plus fetchPriority="high" is enough, and you should not preload when different images are largest on different viewports or when loading is in play. Also remember the browser bugs table: Safari below 16.4 draws a gray border at lazy-loaded image slots, so eager loading doubles as a rendering fix above the fold.
Two habits keep sizes honest. Validate at the viewport you claim: DevTools device emulation applies your sizes string against the emulated width, and the element's attributes panel shows which candidate the browser selected — if a 375px phone picks a 1080w file, the string lies. And when in doubt, be conservative rather than omit: sizes="100vw" is correct for a genuinely full-bleed hero, while an undersized guess (50vw on a full-width image) means upscaling and a visibly soft result. Static imports change only the bookkeeping — Next derives width and height from the file itself — so CSS still controls rendering, and sizes remains the one prop that is yours to declare.
Reserving space and picking one width keep CLS and bytes in line for local files. Remote files fail differently — an unlisted host produces a hard error, not a slow image.
Remote Images: The 'hostname is not configured' Error and the `remotePatterns` Allowlist
Files in /public just work. Point the same component at a CDN and the dev overlay stops you before the page renders:
Error: Invalid src prop (https://cdn.acme.com/p/42.jpg) on `next/image`,
hostname "cdn.acme.com" is not configured under images in your next.config.js
See more info: https://nextjs.org/docs/messages/image-optimize-missing-config
The error is deliberate. Your optimizer is a resize-on-demand proxy: it fetches whatever URL you name, downloads it, re-encodes it, and serves the result from your origin. Without an allowlist, anyone can point raw requests at /_next/image?url=… and turn your server into a free image host — burning CPU and bandwidth, and probing internal hosts through your network. So remote sources must be declared:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.acme.com',
port: '',
pathname: '/p/**',
search: '',
},
],
},
}
There is an older, weaker spelling still found in codebases:
images: { domains: ['cdn.acme.com'] } // deprecated since Next.js 14
domains matches hostnames exactly: no wildcards, and no way to constrain protocol, port, pathname, or query string. On shared infrastructure — s3.amazonaws.com, res.cloudinary.com — that is the difference between "my bucket" and "every tenant on this host". remotePatterns accepts * for a single path segment or subdomain and ** for any number of segments at the end of a pathname or subdomains at the start of a hostname, and search can pin an exact query string ('?v=1727111025337'). Omitting a field implies ** for it, which the docs explicitly warn against — an implied wildcard protocol or path can re-admit exactly what you meant to block.
Failure modes you will actually see in the wild:
- The dev overlay error quoted above — an unconfigured or mismatched host.
400 Bad Requestfrom direct hits on/_next/image?url=…— the pattern table is the contract; a URL matching nothing is rejected at the optimizer, which is why the bug sometimes shows up in server logs rather than in the UI.- A redirect that escapes your patterns — the loader follows up to
maximumRedirects: 3by default and does not re-validate patterns against the redirect target, so lower it to0when your source server should never redirect.
The experience differs by environment, which changes how you debug it. In development you get the overlay with the exact message; in production no overlay appears — the request answers 400 or the browser draws a broken-image box while server logs carry the Invalid src prop thrown by the optimizer. That asymmetry is why pattern changes belong in review, not in a hotfix. Two deployment-adjacent defaults also deserve attention: the optimizer fetches source bodies up to maximumResponseBody: 50 MB (lower it when every source is small, so one bad URL cannot exhaust server memory), and behind a proxy or CDN you must forward the Accept header — format negotiation reads it, and a proxy that strips it degrades output back to the source format.
Neighboring switches follow the same allowlist philosophy. localPatterns restricts which /public paths may be optimized (anything else 400s). dangerouslyAllowSVG stays off because SVG can carry script; if you truly need it, set contentDispositionType: 'attachment' and a contentSecurityPolicy alongside — otherwise prefer unoptimized for icons. And from chapter 1: authenticated remote images need unoptimized regardless of patterns, because the optimizer never forwards your request's headers when it fetches the source.
Verify configuration the way you verify anything else — hit the endpoint directly instead of trusting the component:
curl -sI "https://acme.com/_next/image?url=https%3A%2F%2Fcdn.acme.com%2Fp%2F42.jpg&w=1080&q=75" | head -3
# 200 + cache headers = configured · 400 = missing or mismatched pattern
Images are one set of bytes your renderer waits on. Fonts are the other historically render-blocking class, and they have their own pipeline in Self-Hosted Fonts with next/font.
Quiz
1. Your page renders `<Image src="https://cdn.acme.com/p/42.jpg" width={800} height={600} alt="Photo" />` and the dev overlay reports `hostname "cdn.acme.com" is not configured under images in your next.config.js`. What is the correct fix?
2. A `fill` image inside a 300px-wide card downloads a 1920w variant on a 2560px monitor. What happened?
3. An `<img>` with no declared dimensions loads and pushes article text down, contributing 0.12 CLS. How does next/image prevent this, and what must you provide?
4. You want the hero image to stop delaying Largest Contentful Paint while below-the-fold images stay lazy. Which configuration is correct in current Next.js?
Flashcards
Question
What does next/image emit by default, and what are the pipeline's default format, quality, and cache TTL?
Click to reveal answer
Answer
It emits srcset candidates at widths from deviceSizes/imageSizes pointing at /_next/image, plus loading="lazy" and width/height for aspect-ratio reservation. Defaults: WebP output (negotiated from the Accept header; AVIF is opt-in), quality 75 (Next 16 pins qualities to [75], so ?q=80 answers 400), and minimumCacheTTL 14400s — variants live in <distDir>/cache/images, longer if the source's Cache-Control allows.
Question
Why does omitting sizes on a fill image download too much, and how does srcset generation differ with and without it?
Click to reveal answer
Answer
Without sizes the browser assumes 100vw, so it picks the largest candidate covering the viewport even when CSS renders a 300px card. Without sizes on a fill image Next builds srcset from deviceSizes only (640…3840); with sizes it builds the full list from deviceSizes + imageSizes. A width/height image without sizes gets a short 1x/2x list suited to fixed rendering.
Question
Contrast images.remotePatterns and images.domains, and name the error an unconfigured host produces.
Click to reveal answer
Answer
remotePatterns is the supported allowlist: protocol, hostname (with * for one segment/subdomain and ** for many), port, pathname globs, and exact search. Unconfigured hosts throw the dev overlay 'hostname ... is not configured under images in your next.config.js'; non-matching direct hits on /_next/image answer 400. domains (deprecated since Next 14) matches exact hostnames only — no wildcards, no protocol/port/pathname/search — which is unsafe on shared hosts.
Question
When does the optimizer not forward headers, and what is the default stance on SVG?
Click to reveal answer
Answer
The optimizer never forwards incoming request headers when it fetches the source, so cookie- or token-protected images fail — pass unoptimized for those. SVG is served as-is: it is vector (resizing gains nothing) and can carry script, so a src ending in .svg is automatically unoptimized; dangerouslyAllowSVG is the deliberate opt-out, to be paired with contentDispositionType: 'attachment' and a contentSecurityPolicy.
Question
How do preload, loading, and fetchPriority interact on next/image?
Click to reveal answer
Answer
Default is loading="lazy". preload (added in Next 16; priority is deprecated) injects a head <link rel="preload"> and belongs on the single above-the-fold LCP candidate; docs recommend loading="eager" with fetchPriority="high" for most cases and warn off preload when several images could be LCP per viewport or when loading is set. Everything below the fold keeps the lazy default.
Revision Notes
Key Takeaways
- 1. The pipeline negotiates format (WebP default, AVIF opt-in), serves widths from deviceSizes/imageSizes at quality 75, and caches variants at least 4 hours — the browser still chooses which single candidate to download
- 2. width/height reserve the aspect-ratio box (CLS), fill needs a positioned parent, and sizes mirrors your layout — without it the browser assumes 100vw and always over-downloads
- 3. Remote hosts must be allowlisted; remotePatterns replaces the deprecated domains because it can scope protocol, pathname, and query on shared hosts — misses surface as the 'hostname is not configured' overlay or a 400 from /_next/image
- 4. Exactly one above-the-fold image earns preload (priority is deprecated as of Next 16) or loading="eager" + fetchPriority="high" for LCP; the lazy default is correct for everything else
- 5. The optimizer never forwards auth headers and never resizes SVG by default — unoptimized is the deliberate bypass for both, not a workaround
Interview Tips
- • "Walk me through what happens when a next/image loads." — srcset built from sizes/deviceSizes, browser picks one width, /_next/image fetches the source, resizes, negotiates format from Accept, caches on disk; then quote the defaults: WebP, q=75, 4-hour TTL
- • "My remote image throws 'hostname is not configured'. Why?" — the optimizer is a public resize proxy, so hosts must be allowlisted; show remotePatterns with protocol + pathname, note domains is deprecated since Next 14 because it cannot scope shared hosts
- • "How do you keep images from shifting the page?" — intrinsic width/height (or fill inside an aspect-ratio container) reserve space before bytes arrive; separate that from LCP work, where only the hero goes eager/preloaded
- • "When is unoptimized the right call?" — SVG icons, images behind auth (headers are not forwarded), a CDN that already transforms, or migrating off the optimizer under load; name what you give up: resizing, format negotiation, caching
Cheat Sheet
Optimizing Images with next/image
Pipeline defaults (first request resizes + caches, later requests hit cache):
| Setting | Default | Note |
|---|---|---|
formats |
['image/avif', 'image/webp'] (WebP alone on older versions) |
AVIF opt-in: ~20% smaller, ~50% slower encode |
deviceSizes |
640 750 828 1080 1200 1920 2048 3840 | used with sizes / fill |
imageSizes |
32 48 64 96 128 256 384 | small images with sizes |
quality |
75 | Next 16: qualities: [75], other values → 400 |
minimumCacheTTL |
14400 (4h) | or upstream Cache-Control if larger |
Sizing decision:
// known ratio, fixed slot — reserves aspect-ratio box
<Image src="/hero.jpg" width={2400} height={1350} style={{ width: '100%', height: 'auto' }} alt="" />
// unknown ratio — parent MUST be position: relative/fixed/absolute
<div className="relative aspect-video">
<Image fill src={post.cover} sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" alt="" />
</div>
// LCP hero — exactly one image gets this
<Image src="/hero.jpg" width={2400} height={1350} alt="" preload loading="eager" fetchPriority="high" />
sizes mirrors layout: 100vw full-bleed, 50vw two-column, (max-width: 768px) 100vw, 33vw grid. Missing sizes ⇒ browser assumes 100vw ⇒ over-download.
Remote allowlist:
images: {
remotePatterns: [{ protocol: 'https', hostname: 'cdn.acme.com', pathname: '/p/**', search: '' }],
// domains: ['cdn.acme.com'] — deprecated since Next 14, exact host only
}
Error = hostname "..." is not configured under images; direct /_next/image mismatch = 400; maximumRedirects: 3 by default and redirect targets are NOT re-validated.
Escape hatches: unoptimized for auth-protected images (headers are not forwarded), SVGs, and CDNs that already transform. localPatterns allowlists /public paths.