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.

next/image optimization request flow The Image component emits a srcset of /_next/image URLs. The browser requests one width; the optimizer checks its cache; on a miss it invokes sharp to resize and re-encode per the Accept header and images.formats config, then caches and returns the result behind a CDN. <Image> emits srcset of /_next/image URLs GET ?w&q /_next/image reads Accept cache lookup by url+w+q+fmt HIT → serve MISS sharp resize + re-encode AVIF/WebP CDN / edge caches by URL immutable TTL Browser picks width by sizes + DPR

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:

  • w must appear in deviceSizes ∪ imageSizes. An arbitrary ?w=1337 is rejected with a 400. This is why adding a width to your sizes string 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.
  • q must be an allowed quality. From Next 15, that means membership in images.qualities; before it, any integer 1-100 passed. A quality prop outside the allowlist is a build-time type error in the App Router and a 400 at runtime otherwise.
  • url must be local or match remotePatterns/localPatterns. Hostname, protocol, port and pathname are all matched. A pattern of hostname: '**' re-opens the proxy hole; constrain the pathname as well.
  • The response content-type must be an image, and unless dangerouslyAllowSVG is set, image/svg+xml is 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 restrictive images.contentSecurityPolicy and contentDispositionType: '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.

  • deviceSizes is used when the image is viewport-relative — that is, when your sizes contains a viewport unit like 100vw or 50vw. Next multiplies each device size against the layout to build the srcset, so these values should map to the common device widths and DPRs in your audience.
  • imageSizes is used when the image is a fixed size (a sizes of 240px, 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. imageSizes values are only ever used with deviceSizes, 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.

deviceSizes and imageSizes candidate pools Two rows of width chips. The deviceSizes pool holds 640, 750, 828, 1080, 1200, 1920, 2048 and 3840. The imageSizes pool holds 16, 32, 48, 64, 96, 128, 256 and 384. If the sizes prop contains a viewport unit, the srcset is built from every deviceSizes width, eight variants per image per format. If sizes is a fixed pixel value such as 384 pixels, the srcset is built from the nearest one-x and two-x candidates only, two variants. images.deviceSizes — widths for viewport-relative images 640 750 828 1080 1200 1920 2048 3840 images.imageSizes — small widths appended for fixed-size images 16 32 48 64 96 128 256 384 what does sizes look like? contains a viewport unit (100vw) srcset = every deviceSizes width 640 · 750 · 828 · 1080 · 1200 · 1920 · 2048 · 3840 8 variants per image, per format a fixed px value (384px) srcset = nearest 1x and 2x candidates 384w (1x) · 828w (2x) 2 variants — small assets stay small Every width is a separate encode and a separate cache entry — trim deviceSizes to real breakpoints.

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.jpg with 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 string src and pass width/height yourself.
  • <Image> is a Server Component by default, and it should stay one. Adding onLoad, onError, or any other handler pulls the file across the 'use client' boundary, which ships the component’s props — including a base64 blurDataURL — 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.

Optimizer cache entry states A state machine. The first request for a variant finds no cached entry, a MISS, which triggers a sharp encode that writes a HIT entry served from disk. When the entry passes minimumCacheTTL it becomes STALE, which serves the old bytes and revalidates by re-encoding. If the entry is evicted or a deploy resets the cache directory, the state returns to MISS. first request for a variant MISS no cached variant cold sharp encode resize + AVIF/WebP write HIT x-nextjs-cache: HIT TTL elapsed STALE served, then refreshed revalidate in background evicted · deploy resets cache Every url+w+q+format tuple is its own entry — clearing the cache sends all of them back 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 set unoptimized: true or 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.