Next.js Image Component Optimization
The next/image component is the most widely deployed image optimizer on the web, and also the most widely misconfigured. It is part of Framework & Build-Tool Media Integration, and it packs the whole responsive pipeline — resizing, format negotiation, srcset generation, layout reservation, and priority hinting — behind a single <Image> tag. This guide is a close reading of what that tag actually emits, how the built-in /_next/image optimizer works, and every configuration knob that changes its output: images.formats, deviceSizes, imageSizes, sizes, fill, priority, placeholder, remotePatterns, quality, and unoptimized.
How the built-in optimizer works
When you render <Image src="/hero.jpg" width={1200} height={630} />, Next.js does not put /hero.jpg in the src. It rewrites the URL to its optimization endpoint:
/_next/image?url=%2Fhero.jpg&w=1200&q=75
That endpoint is a request handler backed by sharp. On the first request for a given url+w+q triple it decodes the source, resizes to w, re-encodes to the best format the client’s Accept header supports (per images.formats), and streams the result with a long-lived Cache-Control. Subsequent requests for the same triple are served from the optimizer’s on-disk cache (.next/cache/images by default). The component builds a srcset by repeating this URL across the applicable widths, so the browser can pick a candidate — the optimizer resizes on demand for each width the browser actually requests.
Warning: the on-disk optimizer cache is keyed by url+w+q+Accept. On a serverless deploy where each cold invocation starts with an empty cache, the first visitor to each variant pays the full sharp encode latency. Warming the cache or fronting /_next/image with a durable CDN cache is essential for consistent LCP.
What the endpoint validates before it encodes
/_next/image is a public URL that resizes images on request, which makes it an open proxy unless it is strict. Next.js closes that hole with a validation pass that runs before sharp is ever touched, and every rule in it corresponds to a config key you can trip over:
wmust appear indeviceSizes ∪ imageSizes. An arbitrary?w=1337is rejected with a 400. This is why adding a width to yoursizesstring does nothing unless the value also exists in one of the two config lists — the component will never emit it, and a hand-crafted URL will be refused.qmust be an allowed quality. From Next 15, that means membership inimages.qualities; before it, any integer 1-100 passed. Aqualityprop outside the allowlist is a build-time type error in the App Router and a 400 at runtime otherwise.urlmust be local or matchremotePatterns/localPatterns. Hostname, protocol, port andpathnameare all matched. A pattern ofhostname: '**'re-opens the proxy hole; constrain thepathnameas well.- The response
content-typemust be an image, and unlessdangerouslyAllowSVGis set,image/svg+xmlis refused outright. SVG is an executable document format; serving attacker-supplied SVG from your own origin is a stored-XSS primitive. If you must enable it, pair it with a restrictiveimages.contentSecurityPolicyandcontentDispositionType: 'attachment'. - Animated sources are passed through untouched. An animated GIF or WebP is detected and streamed as-is rather than being flattened to its first frame, so do not expect the optimizer to shrink it. Convert animation to a muted looping
<video>instead.
The response carries Vary: Accept, because the same url+w+q produces AVIF for one client and WebP for another. Any cache in front of the endpoint must honour that header or one client’s format will be served to another — the negotiation failure mode described in Cache-Control headers for image and video assets.
Configuration reference
Everything the optimizer does is governed by the images block in next.config.js.
| Config key | Purpose | Default / note |
|---|---|---|
images.formats |
Preferred output formats in priority order | ['image/webp']; add 'image/avif' first for AVIF |
images.deviceSizes |
Widths used when sizes implies viewport-relative rendering |
[640,750,828,1080,1200,1920,2048,3840] |
images.imageSizes |
Extra small widths for fixed-size images (added to deviceSizes) | [16,32,48,64,96,128,256,384] |
images.qualities |
Allowed quality values (Next 15+ allowlist) |
must include any quality prop you pass |
images.remotePatterns |
Allowlist of remote hosts the optimizer may fetch | empty; required for any external src |
images.minimumCacheTTL |
Floor for the optimizer’s cache lifetime (seconds) | 60; raise for stable assets |
images.loader / loaderFile |
Swap the built-in optimizer for a custom/CDN loader | 'default' |
images.unoptimized |
Bypass the optimizer entirely (emit raw src) |
false |
images.dangerouslyAllowSVG |
Permit SVG through the optimizer | false (SVG is a script vector) |
images.localPatterns |
Restrict which local paths the optimizer will read | unset = all local paths allowed |
images.contentDispositionType |
Content-Disposition on optimizer responses |
attachment; inline if you link to variants directly |
images.contentSecurityPolicy |
CSP applied to optimizer responses | Pair with dangerouslyAllowSVG |
images.disableStaticImages |
Turn off the static-import type for .jpg/.png |
false; only for custom import loaders |
images.path |
Prefix the optimizer URLs are built from | /_next/image; change when serving from a sub-path |
Setting AVIF output
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
images: {
// Order matters: the optimizer tries formats left-to-right against the
// request's Accept header and serves the FIRST match. Listing AVIF first
// means Chrome/Firefox/Safari-16 get AVIF; Safari 14/15 fall through to WebP.
formats: ['image/avif', 'image/webp'],
// AVIF encodes ~20% smaller than WebP here but costs materially more CPU
// per resize. On a serverless optimizer that raises cold-path latency —
// budget for it or offload to a CDN loader.
minimumCacheTTL: 2678400, // 31 days — keep optimized variants around longer
},
};
How deviceSizes and imageSizes shape the srcset
The two width lists serve different image kinds, and understanding which one applies prevents both blurry images and wasteful over-fetching.
deviceSizesis used when the image is viewport-relative — that is, when yoursizescontains a viewport unit like100vwor50vw. Next multiplies each device size against the layout to build thesrcset, so these values should map to the common device widths and DPRs in your audience.imageSizesis used when the image is a fixed size (asizesof240px, an avatar, an icon). These small widths are appended to the candidate pool so a 48px avatar does not have to download a 640px file.imageSizesvalues are only ever used withdeviceSizes, never alone.
Concretely, an image with sizes="(max-width: 768px) 100vw, 384px" produces a srcset that pulls the near-384 entries for desktop and the wider deviceSizes entries for the full-bleed mobile case:
/_next/image?url=%2Fcard.jpg&w=640&q=75 640w,
/_next/image?url=%2Fcard.jpg&w=750&q=75 750w,
/_next/image?url=%2Fcard.jpg&w=828&q=75 828w, …
Tradeoff: every entry in deviceSizes is a distinct variant the optimizer may encode and cache. The default eight-width list is generous; trimming it to the four or five widths your breakpoints actually select cuts optimizer CPU, cache footprint, and (on a metered image CDN loader) transform cost — at the price of slightly coarser DPR matching on unusual screens.
The right-hand branch is the one people forget exists. Because a fixed sizes yields only two candidates, an avatar grid of two hundred users costs the optimizer four hundred variants rather than sixteen hundred — provided the sizes is a plain pixel value. Write sizes="(max-width: 480px) 48px, 48px" and you still land in the fixed branch; write sizes="5vw" for the same 48px avatar and you have just asked the optimizer to prepare all eight deviceSizes widths for a thumbnail. The sizes string is not only a hint to the browser, it is the input that decides how much work your infrastructure does.
Step-by-step: a correct next/image setup
Step 1 — Import local images so dimensions are known
// Static import → Next reads intrinsic width/height at build time and derives
// blurDataURL. This is what lets it reserve layout space and avoid CLS with
// zero extra props.
import Image from 'next/image';
import hero from '@/assets/hero.jpg';
export function Hero() {
return (
<Image
src={hero}
alt="Warehouse floor with autonomous forklifts"
priority // LCP: fetchpriority=high + preload link
placeholder="blur" // uses the auto-derived blurDataURL
sizes="(max-width: 640px) 100vw, 640px"
/>
);
}
Step 2 — Set sizes to match the real layout
The optimizer generates srcset widths from deviceSizes, but it needs sizes to know which one the browser should pick. Omit sizes on a responsive image and Next defaults to 100vw, which on a 4K display selects the 3840 variant for a 600px column — a multi-hundred-kilobyte over-fetch.
// A three-column grid image: full width on phones, half on tablets,
// one-third of a 1200px max container on desktop (~380px).
<Image
src={product}
alt="Product thumbnail"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 380px"
/>
Deriving these percentages correctly is a topic of its own — see how to calculate optimal sizes attribute values.
Step 3 — Choose fill or fixed layout
For a fixed, known display size, pass width/height (or use the static import, which supplies them). For a container-driven image that must cover an arbitrary box, use fill:
// fill: the image becomes position:absolute; inset:0; object-fit:cover.
// The PARENT must be positioned and sized, or the image collapses to 0px.
<div style={{ position: 'relative', aspectRatio: '16 / 9' }}>
<Image
src={cover}
alt="Article cover"
fill
style={{ objectFit: 'cover' }}
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
The full anti-CLS treatment of fill — sized parents, aspect-ratio, and matching sizes — is in fixing CLS with next/image fill and sizes.
Step 4 — Allowlist remote sources
// next.config.js — remote images 403/throw unless the host is allowlisted.
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example-cdn.com',
// Constrain the path so the optimizer cannot be turned into an
// open image proxy for arbitrary URLs on this host.
pathname: '/catalogue/**',
},
],
},
};
Step 5 — Point at a CDN loader when one already resizes
If a CDN already owns transformations, run only that optimizer, not both. Set a loaderFile so <Image> emits CDN transform URLs instead of /_next/image paths. The full walkthrough is in Next.js Image Custom Loader for a CDN. For enterprise DAM systems that sign URLs with HMAC tokens, see the related next/image with custom loader configurations guide, which covers signature preservation across the generated srcset.
Step 6 — Add a blur placeholder correctly
placeholder="blur" renders a tiny, up-scaled preview until the real image paints, smoothing perceived load. For a static import Next derives the blurDataURL automatically at build time; for a remote src there is no build step to derive it, so you must supply one or the placeholder is ignored.
// Local import: blurDataURL is auto-generated — nothing else to do.
import avatar from '@/assets/avatar.jpg';
<Image src={avatar} alt="Member avatar" width={96} height={96} placeholder="blur" />
// Remote src: you MUST pass blurDataURL yourself, or placeholder does nothing.
// Precompute a ~10px base64 preview at build/upload time (e.g. with sharp or plaiceholder).
<Image
src="https://images.example-cdn.com/catalogue/lamp.jpg"
alt="Desk lamp"
width={600}
height={600}
placeholder="blur"
blurDataURL="data:image/webp;base64,UklGR... (tiny 10px preview)"
/>;
Warning: a large blurDataURL defeats its own purpose — it ships inline in the HTML on every render. Keep the preview around 8–16px on its longest edge and a few hundred bytes; anything larger bloats the document and delays the parser.
Static imports and the Server Component boundary
import hero from '@/assets/hero.jpg' is not a string import. A webpack/Turbopack loader turns it into a StaticImageData object — { src, width, height, blurDataURL } — computed at build time by probing the file’s header. That object is what makes width/height optional, what makes placeholder="blur" free, and what makes a missing file a build failure instead of a broken image in production. It also has three consequences worth internalising:
- The dimensions come from the file, not from you. If a designer replaces
hero.jpgwith a differently-proportioned crop, the reserved box changes on the next build with no code edit — good for correctness, occasionally surprising in review. Pin the ratio in CSS (aspect-ratio) if the layout must not move. - You cannot build the import path dynamically.
import(@/assets/${name}.jpg)defeats the static analysis: the bundler cannot know which files to process, so you get either a runtime failure or every asset in the directory bundled. Map keys to eagerly-imported modules instead, or accept a plain stringsrcand passwidth/heightyourself. <Image>is a Server Component by default, and it should stay one. AddingonLoad,onError, or any other handler pulls the file across the'use client'boundary, which ships the component’s props — including a base64blurDataURL— into the client bundle as serialized RSC payload. When you need a load handler, isolate it in a thin client wrapper around the single image that needs it rather than marking the whole page.
Tradeoff: static imports give you correctness for free but tie the asset to a build. Content that changes without a deploy — a CMS field, a user upload — must use a string src, which means you own width, height, and blurDataURL yourself. Deciding per-image which of those two regimes an asset belongs to is the single most useful piece of architecture work in a next/image codebase.
The optimizer cache lifecycle
Almost every “next/image is slow” report resolves to a cache-state question rather than an encoder question. Each entry in .next/cache/images is identified by the full tuple url + w + q + negotiated format, and moves through a small number of states whose transitions you can observe directly through the x-nextjs-cache response header.
A cold entry is a MISS: the handler streams the source, hands it to sharp, writes the encoded result plus a metadata sidecar recording its expiry, and returns the bytes. Subsequent requests inside the entry’s lifetime are a HIT, served from disk without touching sharp. Once the entry is older than its expiry it becomes STALE: Next still serves the cached bytes immediately, then re-encodes in the background so the next request is fresh again — the same stale-while-revalidate shape described in stale-while-revalidate for media assets. Deleting the cache directory, deploying a new build, or landing on a different serverless instance drops the entry entirely and returns it to MISS.
Two numbers govern how long an entry stays out of MISS. images.minimumCacheTTL is a floor, not a fixed value: Next takes the larger of that setting and the max-age on the upstream response. An origin sending Cache-Control: no-store therefore does not disable the optimizer cache — the floor still applies — but an origin sending max-age=31536000 will extend an entry far beyond your configured minimum. When you cannot explain an entry’s lifetime, read the upstream headers before you touch config.
Warning: the cache directory has no bounded size and no LRU eviction of its own. A site with a large catalogue, eight deviceSizes widths, two formats and several quality values can accumulate tens of gigabytes in .next/cache/images on a long-lived container. Either mount it on a volume you monitor, or make the CDN the durable layer and keep the local cache small by leaving minimumCacheTTL low.
Parameter reference
| Prop | Type | Effect |
|---|---|---|
priority |
boolean | Emits fetchpriority="high" and injects <link rel=preload> with matching imagesrcset/imagesizes. One per route. |
sizes |
string | The layout contract; drives which srcset width the browser picks. Required for responsive images. |
fill |
boolean | Absolutely positions the image to fill a sized parent; drops intrinsic width/height. |
quality |
number | Optimizer quality (1–100, default 75). In Next 15+ must be listed in images.qualities. |
placeholder |
'blur' | 'empty' |
'blur' renders blurDataURL (auto for imports) until paint. |
loading |
'lazy' | 'eager' |
lazy (default) for below-fold; never on the LCP image. priority implies eager. |
unoptimized |
boolean | Emits the raw src, skipping /_next/image for this image only. |
blurDataURL |
string | Required for placeholder="blur" on a remote src; auto-derived for static imports. |
loader |
function | Per-image loader overriding images.loader; receives { src, width, quality } and returns a URL. |
overrideSrc |
string | Replaces the emitted src while keeping the generated srcset — for legacy crawlers that ignore srcset. |
onLoad / onError |
function | Client-side events; forces the component into a Client Component in the App Router. |
style |
object | Applied to the <img>. Use it for objectFit/objectPosition under fill, not for sizing. |
alt |
string | Required. An empty string is valid and marks the image decorative. |
When to disable optimization
The optimizer is not always the right layer. unoptimized (per-image or globally via images.unoptimized) makes <Image> emit the raw src while keeping the component’s layout reservation, sizes, and priority behaviour. Reach for it when:
- The image is already an optimized, correctly sized asset (a pre-generated AVIF from your build pipeline) and a second re-encode would only add a lossy pass and latency.
- You use
output: 'export'for a fully static site with no Node server to run/_next/image. Static export cannot run the on-request optimizer, so you either setunoptimized: trueor configure a custom/CDN loader that resizes at the edge — see Next.js Image Custom Loader for a CDN. - The asset is an SVG or a tiny icon where resizing yields nothing.
// This PNG is a pre-optimized sprite; skip the optimizer but keep the sized box.
<Image src="/sprites/logo.png" alt="Logo" width={140} height={32} unoptimized />
Tradeoff: unoptimized forfeits format negotiation and responsive resizing for that image — the browser gets exactly the file you named, at its full byte weight, for every viewport. Use it deliberately, not as a blanket workaround for a sharp install problem.
Tradeoffs and self-hosting
Tradeoff: Vercel vs self-hosted. On Vercel the optimizer runs as a managed, globally cached function. Self-hosting means the /_next/image route runs in your Node server or container, and its cache lives on that instance’s disk — which resets on every deploy and is not shared across replicas. Front it with a CDN keyed on the full optimized URL, and persist or share .next/cache/images.
Warning: sharp in Docker. next start needs sharp installed for optimization; on Alpine base images the prebuilt libvips binary may be missing, causing the optimizer to fall back to the slower @squoosh/lib (removed in newer Next) or to error. Install sharp explicitly and use a glibc base (node:20-slim) or the correct --platform build so the native binary matches the runtime.
Tradeoff: caching /_next/image. The optimizer sets Cache-Control derived from the source’s own headers and minimumCacheTTL. If your upstream sends no-store, the optimizer will not cache aggressively — audit the source headers, covered in Cache-Control headers for image and video assets.
| Failure mode | Cause | Fix |
|---|---|---|
Optimizer throws on remote src |
Host not in remotePatterns |
Add host + constrained pathname |
| First view slow, later views fast | Cold optimizer cache per variant | Warm cache; CDN-front /_next/image |
| Images render huge/blurry | sizes missing → 100vw default |
Set accurate sizes per breakpoint |
Build/runtime error: no sharp |
Native binary missing in container | Install sharp; use glibc base image |
| Safari 14 gets broken image | AVIF forced with no WebP fallback | Keep 'image/webp' after 'image/avif' in formats |
quality prop ignored/errors |
Value not in images.qualities (Next 15+) |
Add the value to the qualities allowlist |
400 from /_next/image on a hand-built URL |
w is not in deviceSizes ∪ imageSizes |
Use only widths present in the config lists |
| Animated GIF unchanged and still huge | Animated sources are passed through, never re-encoded | Convert to a muted looping <video> upstream |
fill image collapses to zero height |
Parent is not position: relative with a real height |
Give the parent position:relative plus aspect-ratio or an explicit height |
| Blur placeholder never appears on remote images | No blurDataURL supplied and none can be derived |
Precompute a ~10px base64 preview at upload time |
| Optimizer serves WebP to an AVIF-capable client | A cache in front ignores Vary: Accept |
Add Accept to the CDN cache key, or normalise it at the edge |
| Cache directory grows without bound | No eviction on .next/cache/images |
Lower minimumCacheTTL; make the CDN the durable layer; monitor the volume |
Debugging
Inspect the generated markup and network activity to confirm the optimizer is behaving:
# 1. Confirm the emitted srcset points at /_next/image with the widths you expect.
curl -s https://localhost:3000/ | grep -o '/_next/image[^"]*' | head
# 2. Request one variant with an AVIF-capable Accept header and check the format.
# Expect: content-type: image/avif (falls to image/webp for Safari 14 Accept)
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
'https://localhost:3000/_next/image?url=%2Fhero.jpg&w=1200&q=75' \
| grep -iE 'content-type|cache-control|x-nextjs-cache'
x-nextjs-cache: HIT confirms a warm optimizer cache; MISS on repeat requests means the cache is not persisting (common on serverless without a shared store). In DevTools, the Network panel’s “Img” filter shows the chosen width — if a desktop request is pulling the 3840w file, your sizes is wrong.
Prove the format negotiation both ways
A single curl with an AVIF Accept header only shows you the happy path. Run the Safari-14 case too, because that is the one that silently breaks when 'image/webp' is dropped from formats:
# Safari 14/15 send webp but not avif. The response MUST be image/webp —
# if it comes back image/avif, an intermediary is ignoring Vary: Accept.
curl -sI -H 'Accept: image/webp,image/apng,image/*,*/*;q=0.8' \
'https://localhost:3000/_next/image?url=%2Fhero.jpg&w=1200&q=75' | grep -i content-type
# An ancient client with no image preferences must still get something
# decodable — expect image/jpeg or image/png, never image/avif.
curl -sI -H 'Accept: */*' \
'https://localhost:3000/_next/image?url=%2Fhero.jpg&w=1200&q=75' | grep -i content-type
Read the variant the browser actually chose
// DevTools console. currentSrc is the resolved /_next/image URL after srcset
// selection; parse its w parameter to see which variant the browser picked.
[...document.querySelectorAll('img')].map((img) => ({
w: new URL(img.currentSrc, location.href).searchParams.get('w'),
natural: img.naturalWidth,
rendered: img.getBoundingClientRect().width,
}));
The number to watch is w against rendered × devicePixelRatio. A w far above that product means sizes is overstating the layout; a w below it means the browser had no candidate large enough and is upscaling — add a wider entry to deviceSizes, or check that the image is not being served through unoptimized.
Confirm the preload the priority prop injects
# priority should add a <link rel="preload" as="image"> carrying imagesrcset
# and imagesizes that MATCH the <img>. A mismatch makes the browser fetch two
# different candidates — the preload is wasted and LCP does not improve.
curl -s https://localhost:3000/ | grep -oE '<link[^>]*rel="preload"[^>]*as="image"[^>]*>'
If imagesizes on the preload differs from sizes on the <img>, the two selections diverge and you pay for both. The scheduling consequences of that duplication are covered in using fetchpriority to optimize critical media.
Related
- Next.js Image Custom Loader for a CDN — replace
/_next/imagewith a Cloudflare/Imgix/Cloudinary transform loader - Fixing CLS with next/image fill and sizes — eliminate layout shift with sized parents and matching sizes
- Framework & Build-Tool Media Integration — how next/image compares to @nuxt/image, astro:assets, and vite-imagetools
- next/image with custom loader configurations — signed-URL and DAM loaders that preserve HMAC parameters
- Mastering srcset and sizes for responsive layouts — the sizes/srcset model the component depends on
- Cache-Control headers for image and video assets — how source headers shape the optimizer’s cache