
Next.js Image Component
Images are usually the single heaviest thing on a web page, and they are also the thing most likely to wreck your Core Web Vitals if you leave them unmanaged. A raw <img> tag ships whatever file you point it at, at full resolution, with no lazy loading, no format negotiation, and no protection against layout shift while it loads. next/image exists to take all of that off your plate — but "off your plate" doesn't mean "invisible." The component has a genuinely large prop surface and a matching set of next.config.js options, and knowing what each one actually does is the difference between shipping fast, correctly-sized images and quietly serving a 4000px hero image to a phone.
This is a full reference walkthrough of next/image: every prop, every configuration option, the deprecated bits you'll still run into in older codebases, and the parts of the mental model the docs assume you already have. If you want the beginner-level "how do I show a picture" version, that's covered elsewhere on this blog — this one assumes you already know the component exists and want to know exactly how far you can push it.
The Non-Negotiable Props
Every <Image> needs a src and an alt. Those two are required, full stop — there's no cover a component author can throw over a missing alt, because accessibility tooling and search engines depend on it. If the image is genuinely decorative, the correct move is alt="", not omitting the prop.
import Image from "next/image";
export default function Page() {
return (
<Image
src="/profile.png"
width={500}
height={500}
alt="Picture of the author"
/>
);
}
src accepts three shapes: an internal path string (served from /public), an absolute external URL (which must be allow-listed via remotePatterns — more on that below), or a static import. The static import path is worth calling out because it changes what other props you need — Next.js reads the file's actual dimensions at build time, so width and height become optional.
import profile from "./profile.png";
export default function Page() {
return <Image src={profile} alt="Picture of the author" />;
}
One security detail buried in the src section that's easy to miss: the default loader will not forward request headers when fetching the source image. If your image lives behind an authenticated endpoint, the optimizer simply won't be able to fetch it — you'll need unoptimized for that case, which we'll get to.
Width, Height, and the Layout Shift Contract
width and height aren't styling props — they're metadata. They tell the browser the image's aspect ratio so it can reserve the correct amount of space in the layout before the image has actually downloaded. That's the entire mechanism behind Next.js's built-in Cumulative Layout Shift protection. Setting width={500} height={500} doesn't render the image at 500×500 pixels; the rendered size is controlled by CSS. Mixing these two ideas up is one of the most common early mistakes with this component — people set width/height expecting to control display size, then fight the component when the actual rendered dimensions don't match, not realizing style or a CSS class is what they needed all along.
You must supply both unless the image is statically imported or you're using fill. If you genuinely don't know the image's dimensions ahead of time — user-uploaded content is the classic case — fill is the documented escape hatch.
fill: Filling the Parent Instead of Declaring a Size
<div style={{ position: "relative", width: "400px", height: "300px" }}>
<Image src="/profile.png" fill alt="Profile" />
</div>
fill makes the image expand to match its parent element, which must be positioned — relative, fixed, or absolute — because the <img> underneath uses position: absolute internally. If you forget the positioned parent, the image will size against the nearest positioned ancestor further up the tree, which is rarely what you want and produces a very confusing bug.
Once you're in fill territory, objectFit (via the style prop) decides how the image behaves inside its box: contain scales down to fit without cropping, cover fills the box and crops the overflow. There's no default objectFit applied — an unstyled fill image just stretches to match the container's aspect ratio, distortion included, which surprises people the first time they hit it.
sizes: The Prop Everyone Skips and Shouldn't
This is the one that actually determines how much bandwidth your responsive images consume, and it's also the one most tutorials gloss over. sizes tells the browser which of the generated srcset candidates to pick at a given viewport width:
<Image
fill
src="/example.png"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
alt="Responsive example"
/>
Here's the part that isn't obvious from the prop name alone: sizes doesn't just influence which candidate the browser picks — it changes which candidates Next.js generates in the first place. Without sizes, Next.js assumes the image is fixed-size and only generates a narrow srcset (roughly a 1x/2x pair for pixel-density scaling). With sizes present, it generates a full range of widths from your deviceSizes and imageSizes config, tuned for genuinely responsive layouts. Leave sizes off a fill image or a CSS-responsive image, and the browser defaults to assuming the image is as wide as the viewport (100vw) — which means it may download a full-viewport-width image on a component that's actually rendering at 300px. This is the single most common reason a "fully optimized" Next.js image component still shows up as an oversized-image warning in Lighthouse.
Quality, Format Negotiation, and the qualities Allowlist
quality is an integer from 1–100 (default 75) that trades file size for fidelity:
<Image quality={75} src="/photo.jpg" width={800} height={600} alt="Photo" />
As of Next.js 16, there's a wrinkle here that catches people migrating from older versions: quality values are now constrained by a qualities allowlist in next.config.js, and that allowlist is required starting in v16 rather than optional:
// next.config.js
module.exports = {
images: {
qualities: [25, 50, 75, 100],
},
};
If a quality prop doesn't match an entry in this array, Next.js silently coerces it to the nearest allowed value in development (with a console warning) — but if the raw optimization API is hit directly with a disallowed quality, it returns a 400. This exists specifically to stop someone from hammering your image endpoint with arbitrary quality values to burn your compute budget. If you set quality={80} somewhere in your codebase and only configured qualities: [50, 75, 100], you'll get 75 back with a dev-time warning — worth knowing before you spend twenty minutes wondering why your "80" images look identical to your "75" ones.
Format selection is a separate, related config surface:
module.exports = {
images: {
formats: ["image/avif", "image/webp"],
},
};
Next.js reads the request's Accept header and serves the first configured format the browser supports, falling back to the original format for animated images or unsupported browsers. The docs are honest about the tradeoff here: AVIF compresses roughly 20% smaller than WebP but takes about 50% longer to encode, and — this is the part worth budgeting for — configuring multiple formats means Next.js caches each format separately on disk. Two formats roughly doubles your image cache storage footprint. If you're self-hosting behind your own CDN or reverse proxy, you also need to make sure that proxy forwards the Accept header, or format negotiation silently stops working.
Loading Behavior: loading, preload, and the Death of priority
If you've used next/image before Next.js 16, you'll remember priority. It's deprecated now, replaced by preload — same underlying idea, clearer name:
<Image src="/hero.jpg" preload={true} width={1200} height={600} alt="Hero" />
preload={true} inserts a <link rel="preload"> in the document <head>, which is the right call specifically for your Largest Contentful Paint (LCP) candidate — typically a hero image sitting above the fold. The docs are specific about when not to reach for it: don't use it when several images could plausibly be the LCP element depending on viewport (you'd be preloading images that might not even render), and don't combine it with loading or fetchPriority, since those control an overlapping concern. In most cases where you're tempted to reach for preload but aren't dealing with a genuine LCP candidate, loading="eager" or a fetchPriority="high" attribute is the more precise tool.
loading itself defaults to "lazy" — the image only loads once it's within a calculated distance of the viewport. Setting it to "eager" forces immediate loading regardless of scroll position, which you'd want for anything above the fold that isn't already using preload.
There's also decoding (async by default, with sync and auto available), which is a browser hint about whether to block other rendering on this image's decode step — a genuinely minor knob that's rarely worth touching, but it's there.
Placeholders and Perceived Performance
placeholder controls what shows while the real image is still loading:
<Image
src="/photo.jpg"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
width={800}
height={600}
alt="Photo"
/>
"empty"(default) — nothing, just blank space until load."blur"— a blurred stand-in, generated fromblurDataURL.- A raw
data:image/...URL — used directly as the placeholder.
The genuinely useful detail here: if src is a static import of a jpg/png/webp/avif (and it isn't animated), Next.js generates the blurDataURL automatically — you get blur-up placeholders for free with zero extra props. For dynamic or remote images, you have to supply blurDataURL yourself, usually via a tiny (10px or smaller) pre-generated thumbnail. The docs' own warning is worth repeating: a large blurDataURL actively hurts performance, since it's inlined directly into the HTML payload. Keep it tiny.
Event Callbacks and the Client Component Requirement
onLoad and onError are function props, and any prop that accepts a function forces the component using it into Client Component territory — you can't serialize a function across the server/client boundary, so anywhere you attach one of these, you need 'use client' at the top of the file.
"use client";
import Image from "next/image";
export default function Avatar() {
return (
<Image
src="/avatar.png"
width={64}
height={64}
alt="Avatar"
onLoad={(e) => console.log(e.target.naturalWidth)}
onError={(e) => console.error("Failed to load", e.target.id)}
/>
);
}
onLoadingComplete still shows up in older codebases and tutorials — it's deprecated since Next.js 14 in favor of onLoad, and it behaves slightly differently (it receives the <img> element directly rather than an event object). If you're maintaining an older project, know that it still works but should be migrated on sight.
unoptimized, overrideSrc, and Escaping the Pipeline
Not every image benefits from the optimization pipeline. Tiny icons under a kilobyte, SVGs, and animated GIFs are the textbook cases — optimizing them either does nothing useful or actively breaks them (SVGs are vector, so "resizing" them via the raster pipeline is nonsensical, and animated formats lose their animation if run through most image transforms).
<Image src="/icon.svg" unoptimized width={24} height={24} alt="" />
You can also flip this globally in next.config.js with images: { unoptimized: true } — a common move for output: 'export' static-export projects, since the optimization API is a server-side feature that doesn't exist in a purely static build.
overrideSrc is narrower and more situational: when next/image renders, it generates both a srcset and a computed src pointing at the internal /_next/image optimization endpoint. If you're migrating an existing site from raw <img> tags and need to preserve the original src attribute for SEO reasons — search engines have already indexed and ranked that exact URL — overrideSrc lets you keep the srcset optimization benefits while pinning the visible src to the original path.
Remote Images: remotePatterns, Not domains
If your src is an absolute URL, Next.js needs explicit permission to fetch and optimize it — otherwise anyone could point your image endpoint at arbitrary URLs and effectively use your server as a free image proxy. remotePatterns is the current, correct way to allow-list sources:
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**.example.com",
port: "",
pathname: "/account123/**",
search: "",
},
],
},
};
Wildcards work at the segment level: * matches one path segment or subdomain, ** matches any number of segments — but only at the start (for subdomains) or end (for paths), never in the middle. Be as specific as you can. The docs flag this directly: if you omit protocol, port, pathname, or search, a wildcard is implied for the missing piece, and that's a real attack surface if you're not careful — someone finding a permissive remotePatterns entry can potentially get your server to fetch and optimize arbitrary content from an allowed host.
One subtlety that isn't obvious until it bites you: if an allowed remote URL responds with an HTTP redirect, Next.js follows it without re-validating the redirect target against remotePatterns. That's a real gap, and it's why maximumRedirects exists as a config option — set it to 0 if you want to disable redirect-following entirely for a tighter security posture.
domains is the predecessor to remotePatterns, deprecated since Next.js 14. It only lets you allow-list hostnames with no control over protocol, port, path, or query string. If you see it in a codebase you're maintaining, migrating to remotePatterns is a genuine security improvement, not just a style preference.
localPatterns does the analogous job for internal paths — restricting which /public paths are even eligible for optimization, blocking everything else with a 400. Useful if different parts of your app have very different trust levels for user-supplied local paths.
Custom Loaders
If you don't want Next.js's built-in optimization API — because you're using Cloudinary, imgix, a CDN with its own transform API, or anything else — you can supply a loader function per-instance:
"use client";
import Image from "next/image";
const imageLoader = ({ src, width, quality }) => {
return `https://example.com/${src}?w=${width}&q=${quality || 75}`;
};
export default function Page() {
return (
<Image
loader={imageLoader}
src="me.png"
alt="Picture of the author"
width={500}
height={500}
/>
);
}
Note the 'use client' again — same rule as onLoad/onError, since loader is a function prop. If you want every instance in the app to use the same custom loader without repeating this everywhere, loaderFile in next.config.js does it globally instead:
module.exports = {
images: {
loader: "custom",
loaderFile: "./my/image/loader.js",
},
};
The Configuration Surface in next.config.js
Beyond format and remote-pattern config, there's a cluster of options that shape the optimization pipeline's operational behavior — the kind of thing you tune once, on a real production incident, and then forget about until the next one:
deviceSizes and imageSizes define the breakpoint widths used to build srcset candidates. deviceSizes defaults to [640, 750, 828, 1080, 1200, 1920, 2048, 3840]; imageSizes (defaulting to [32, 48, 64, 96, 128, 256, 384]) is specifically for images that use sizes to indicate they're smaller than full viewport width — keep those values below your smallest deviceSizes entry, or you're generating redundant candidates.
minimumCacheTTL sets how long optimized images stay cached (default 4 hours, i.e. 14400 seconds). The actual expiration is whichever is larger: this value, or the upstream image's own Cache-Control header. Here's the operationally important part the docs are blunt about: there is currently no mechanism to invalidate this cache. If you need to force a refresh, your options are changing the src (a cache-busting query param, effectively) or manually deleting <distDir>/cache/images. This is exactly why static imports — which hash the file contents into the URL and cache forever as immutable — are the recommended path when you can use them; you sidestep the invalidation problem entirely.
maximumDiskCacheSize, maximumResponseBody, and maximumRedirects are resource-limiting knobs worth setting deliberately on memory- or storage-constrained hosts rather than trusting the defaults (50% of available disk space at startup, 50MB max source fetch, and 3 redirects respectively). If you know your source images are always small, dropping maximumResponseBody to something like 5MB is a cheap way to protect a small server from an accidentally-huge upstream image.
dangerouslyAllowSVG, contentDispositionType, and contentSecurityPolicy are a matched set. SVGs are disabled from optimization by default for a real reason: SVG can embed scripts, and without care that's an XSS vector delivered through what looks like an innocuous image tag. If you must serve SVGs through the optimizer, the docs strongly recommend pairing dangerouslyAllowSVG: true with both a strict CSP (script-src 'none'; sandbox;) and contentDispositionType: 'attachment', which forces the browser to download rather than render the file inline if visited directly. Treat all three as a package deal, not three independent options.
dangerouslyAllowLocalIP is a narrow escape hatch for self-hosted deployments on private networks with split-horizon DNS setups (where a hostname resolves differently depending on whether the request originates inside or outside your network). It defaults to false for good reason — flipping it on without understanding the SSRF (server-side request forgery) implications is a genuine risk, not a formality.
getImageProps: Escaping the Component When You Need To
Sometimes you don't want <Image> itself — you want the computed srcset/src values to hand to something else: a <picture> element for art direction, a raw <img> inside a <figure>, or even a CSS background-image.
import { getImageProps } from "next/image";
function getBackgroundImage(srcSet = "") {
const imageSet = srcSet
.split(", ")
.map((str) => {
const [url, dpi] = str.split(" ");
return `url("${url}") ${dpi}`;
})
.join(", ");
return `image-set(${imageSet})`;
}
export default function Hero() {
const {
props: { srcSet },
} = getImageProps({ alt: "", width: 1920, height: 1080, src: "/hero.jpg" });
return (
<main style={{ backgroundImage: getBackgroundImage(srcSet) }}>
<h1>Hello World</h1>
</main>
);
}
This also skips React useState internally, which the docs note as a minor performance win — but the tradeoff is that it can't be combined with placeholder, since there's no component instance around to swap the placeholder out once loading finishes. Art-direction (different crops for mobile vs. desktop) and light/dark theme image switching are the two textbook use cases the docs walk through, both structured around calling getImageProps twice with different source images and picking between them with a <picture> element or a CSS media query.
Known Browser Quirks
A few of these are worth knowing before you spend an afternoon debugging what looks like a Next.js bug but is actually a Safari/Firefox rendering quirk: Safari 15 through 16.3 render a visible gray border on lazy-loaded images before they finish loading (fixed in 16.4) — the documented workaround is a CSS @supports block targeting WebKit, or falling back to loading="eager" for above-the-fold images. Firefox 67+ shows a white flash on load, which enabling AVIF or using a placeholder mitigates. None of this is something next/image can paper over completely — it's inherent to how these browsers implement native lazy loading.
Common Mistakes
A short list of the ways people misuse this component in practice, none of which throw an error to warn you:
- Skipping
sizeson a responsive image. You'll get a technically-correctsrcset, generated for the wrong assumption (full viewport width), and quietly ship oversized images. - Confusing
width/heightwith rendered size. They set aspect ratio for layout-shift prevention, not display dimensions — that's whatstyleor a CSS class controls. - Using
preload/priorityon more than one image per page. If several images compete for "this is the LCP element," you're preloading images that may never even be the actual bottleneck, wasting the very bandwidth priority you're trying to protect. - Forgetting the
'use client'boundary ononLoad/onError/loader. These are function props; they need a client boundary, and the error message you get when you forget isn't always an obvious pointer back to this rule. - Wide-open
remotePatternswildcards.hostname: '**'technically "works" and is exactly the kind of shortcut that turns into an incident later.
Key Takeaways
| Prop / Config | What it actually controls |
|---|---|
src / alt | Required on every image; alt="" for purely decorative images |
width / height | Aspect ratio for layout-shift prevention, not rendered size |
fill | Expands to a positioned parent; pair with objectFit |
sizes | Determines both which srcset candidate loads and which candidates get generated |
quality / qualities | Per-image fidelity, constrained by a required allowlist as of v16 |
preload (formerly priority) | Use once, on the actual LCP image only |
placeholder / blurDataURL | Automatic for static imports; manual and size-capped for remote images |
remotePatterns | Required allow-list for external images; prefer over deprecated domains |
unoptimized | Escape hatch for SVGs, tiny icons, animated GIFs, and static exports |
minimumCacheTTL | No invalidation mechanism exists — keep it low unless the source is immutable |
dangerouslyAllowSVG | Never enable without contentSecurityPolicy and contentDispositionType alongside it |
next/image front-loads a lot of decisions so that, most of the time, you can drop in src/width/height/alt and get a genuinely well-optimized image for free. The rest of this prop surface exists for the cases where "most of the time" isn't good enough — remote sources you don't control, SVGs you have to serve anyway, LCP images that need to win the race, or an optimization pipeline that needs to behave differently under real production load. Knowing which knob solves which problem is what separates "using next/image" from actually getting the performance it's designed to deliver.


