
Next.js Image Optimization
Images are usually the heaviest thing on a web page, and they're also the easiest way to accidentally wreck your Core Web Vitals. A single unoptimized hero image can single-handedly blow your Largest Contentful Paint budget, and a page full of images with no reserved space will jump around as they load, tanking your Cumulative Layout Shift score. Browsers don't optimize this for you — plain <img> tags serve whatever file you give them, at whatever size you give them, to every device regardless of screen size or network speed.
Next.js ships a built-in <Image> component specifically to close that gap. It isn't a styling convenience or a nice-to-have wrapper — it's doing real, non-trivial work on every request: resizing images to match the requesting device, converting them to modern formats like WebP or AVIF, reserving layout space before the image has even downloaded, and deferring offscreen images until they're actually needed. Once you understand the model it's built on, you'll find yourself reaching for very few of the manual performance tricks (lazy-loading libraries, responsive srcset generators, blur-up placeholder scripts) that plain React and HTML require you to hand-roll.
What the Image Component Is Actually Solving
It helps to think of next/image as four separate problems bundled into one component, because each one addresses a distinct failure mode you'd otherwise have to solve yourself:
Size optimization. Instead of shipping one file to every visitor, Next.js generates and serves a size appropriate to the requesting device, in a modern format like WebP when the browser supports it. A visitor on a phone gets a phone-sized image; a visitor on a 4K monitor gets something larger. You upload one source image and stop thinking about export sizes entirely.
Visual stability. The component reserves the image's aspect ratio in the layout before the image data has arrived, which is exactly what prevents Cumulative Layout Shift. This is arguably the single biggest reason to use <Image> over <img> — CLS is notoriously hard to fix any other way once you have dynamic, unknown-dimension content.
Faster page loads. Images that aren't currently in the viewport are deferred using native browser lazy loading, and you can optionally show a blurred placeholder while the full image streams in, so the page feels like it's loading progressively rather than popping in unannounced.
Asset flexibility. You aren't limited to images that live in your repo. Remote images — from a CMS, an S3 bucket, a user-upload pipeline — can be resized on demand too, as long as you tell Next.js which remote hosts you trust.
None of this is exotic technology. It's the same performance advice that's been floating around web performance circles for a decade (serve responsive images, avoid layout shift, lazy-load offscreen content). The value of next/image is that it makes doing the right thing the path of least resistance, instead of something you have to remember to implement by hand on every image in your app.
Getting Started with the Component
You import Image from next/image instead of using the native img tag:
// app/page.tsx
import Image from "next/image";
export default function Page() {
return (
<Image
src="/profile.png"
alt="Picture of the author"
width={500}
height={500}
/>
);
}
Two things are worth flagging immediately, because they trip up almost everyone coming from plain React:
alt is required. Not "recommended" — the TypeScript types will fail to compile if you omit it. This is a deliberate accessibility decision baked into the API, not an optional prop you can skip on a personal project. Give it real, meaningful alt text describing the image content, or an empty string (alt="") if the image is purely decorative — never leave it as a placeholder like alt="image".
width and height are required too, unless you're doing one of two specific things: statically importing the image, or using the fill prop. This isn't about the rendered size of the image on the page (CSS still controls that) — it's about the image's intrinsic aspect ratio, which the browser needs up front to reserve the correct amount of space before the pixels have downloaded. Skip this, and you're back to the classic CLS problem next/image exists to solve.
Local Images and the public Folder
The simplest case is an image that lives in your project. Static assets — images, fonts, favicons — go in a public folder at your project root, and anything in there is served from the site's base URL.
my-app/
├─ app/
│ └─ page.tsx
└─ public/
└─ profile.png
// app/page.tsx
import Image from "next/image";
export default function Page() {
return (
<Image
src="/profile.png"
alt="Picture of the author"
width={500}
height={500}
/>
);
}
Note the leading slash — src="/profile.png" maps to public/profile.png, not a path relative to the component file. This confuses people used to bundler-relative imports, but it's consistent with how the public folder has always worked in Next.js: everything in it is served verbatim from the root of your domain.
Static Imports: Let Next.js Figure Out the Dimensions
There's a second, often more convenient way to reference a local image — importing it directly as a module:
// app/page.tsx
import Image from "next/image";
import ProfileImage from "./profile.png";
export default function Page() {
return (
<Image
src={ProfileImage}
alt="Picture of the author"
// width, height, and blurDataURL are all
// inferred automatically from the file
/>
);
}
When you import an image file this way, Next.js reads the file at build time and automatically knows its width, height, and — for supported formats — generates a blurDataURL for the blur-up placeholder effect, all without you specifying anything. This is genuinely one of the nicer ergonomic wins in the App Router: for any image that's checked into your repo, you get every optimization for free, with zero manual dimension bookkeeping. I'd default to this pattern for anything that isn't dynamically generated at request time — logos, icons, marketing images, illustrations bundled with the app.
The one wrinkle is that static import statements need a literal, statically-analyzable path — you can't build the import path from a runtime variable. If you're pulling an image whose filename depends on data (say, a blog post's slug), use a dynamic import() instead, inside an async Server Component:
// app/blog/[slug]/page.tsx
import Image from "next/image";
async function PostImage({
imageFilename,
alt,
}: {
imageFilename: string;
alt: string;
}) {
const { default: image } = await import(
`../content/blog/images/${imageFilename}`
);
// image still carries width, height, and blurDataURL
return <Image src={image} alt={alt} />;
}
There's a subtlety in how the bundler resolves this that's worth understanding rather than just copy-pasting: the path needs a static prefix (../content/blog/images/), and everything matching that prefix gets bundled at build time — the dynamic part only selects among files that already exist in your repo. This is actually a safety feature, not a limitation: because the set of possible files is fixed at build time, a filename that ultimately comes from user input can't be used to reach outside that directory and pull in an arbitrary file.
Remote Images: More Power, More Configuration
Plenty of real applications don't store images in the repo at all — they come from a CMS, a user upload bucket, or a media CDN. You can pass a full URL as src:
import Image from "next/image";
export default function Page() {
return (
<Image
src="https://s3.amazonaws.com/my-bucket/profile.png"
alt="Picture of the author"
width={500}
height={500}
/>
);
}
Two things change compared to local images. First, you have to supply width and height yourself — Next.js can't inspect a remote file at build time the way it inspects a local one, so there's no dimension to infer. Second, and much more important: you can't optimize an arbitrary remote URL by default. You have to explicitly allow-list which remote hosts Next.js is permitted to fetch and optimize, via remotePatterns in next.config.js:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "s3.amazonaws.com",
port: "",
pathname: "/my-bucket/**",
search: "",
},
],
},
};
export default config;
This isn't bureaucratic friction — it's a real security boundary. The Image Optimization endpoint is effectively a server-side proxy that will fetch whatever URL you point it at and re-serve it. Without an allow-list, that's an open door for server-side request forgery: someone could pass an arbitrary src and get your server to fetch internal network resources or unrelated third-party content on their behalf. Be as narrow as you can with pathname and hostname — a bucket-scoped pattern like /my-bucket/** is meaningfully safer than a bare wildcard covering an entire domain. Wildcards do work (* matches one path segment or subdomain, ** matches any number, but only at the start of a hostname or the end of a path — not in the middle), so hostname: "**.example.com" is a legitimate way to allow every subdomain of a CDN without allowing anything else.
One easy-to-miss gotcha: any redirect the remote server issues while Next.js is fetching an allowed URL is followed without re-validating remotePatterns against the redirect target. That's convenient when a CDN legitimately redirects to a versioned asset URL, but it also means a compromised or misconfigured upstream host could redirect your image loader somewhere you didn't intend. If that's a concern for your setup, you can cap or disable redirect-following with maximumRedirects (it defaults to 3; set it to 0 to refuse redirects entirely).
width, height, and fill: Reserving Layout Space
The width and height props are the mechanism the browser uses to reserve space for an image before it loads — nothing more. They set the aspect ratio, not the rendered size on the page; the rendered size is still entirely up to CSS. This is a common point of confusion: people set width={500} expecting a 500px-wide image, then are surprised when a max-width: 100% rule in their stylesheet makes it render at some completely different size. That's correct behavior — width/height exist purely to prevent layout shift, and CSS is still in charge of the actual box.
When you genuinely don't know an image's dimensions ahead of time — user-uploaded avatars of arbitrary aspect ratio, a hero banner pulled from a CMS field that could be portrait or landscape — reach for fill instead:
<div style={{ position: "relative", width: "400px", height: "300px" }}>
<Image
src="/banner.jpg"
alt="Promotional banner"
fill
style={{ objectFit: "cover" }}
/>
</div>
fill makes the image expand to completely cover its nearest positioned ancestor, which means that ancestor must have position: relative, fixed, or absolute — the <Image> itself renders with position: absolute under the hood, and it sizes against whatever positioned box wraps it. If you forget the wrapper's positioning, the image either collapses to zero height or escapes its intended container, and it's one of the most common "why is my image huge/invisible" bugs people file. Pair fill with objectFit: "cover" (crop to fill, preserving aspect ratio) or "contain" (shrink to fit, preserving aspect ratio, possibly with letterboxing) depending on whether cropping is acceptable for that image.
The sizes Prop: Telling the Browser What Size You Actually Need
This is the prop most people skip, and skipping it quietly undoes a chunk of the size-optimization benefit you came here for. By default — without sizes — Next.js assumes the image will render as wide as the viewport and generates a limited srcset (basically 1x/2x pixel-density variants), which is the right assumption for a fixed-size image like an avatar, but the wrong assumption for anything that's actually laid out responsively (a fluid-width hero, a grid tile, a card thumbnail).
<Image
fill
src="/example.png"
alt="Example"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
Once you supply sizes, Next.js switches to generating a full width-based srcset (multiple discrete widths like 640w, 750w, 828w, and so on, driven by the deviceSizes/imageSizes config described below), and the browser picks whichever candidate best matches the actual rendered width at the actual viewport size — not the full viewport width. If your image only ever occupies a third of the screen on desktop, an accurate sizes value is the difference between the browser downloading a 1920px-wide image it didn't need and a 640px-wide one that's plenty. You should treat sizes as required any time you're using fill, or any time CSS is making the image's rendered width responsive rather than fixed.
Loading Priority: preload Has Replaced priority
Here's something worth calling out explicitly, because if you've used Next.js before and remember reaching for a priority prop on above-the-fold images, that guidance has changed: as of Next.js 16, priority is deprecated in favor of a new preload prop. The behavior it controls is the same idea — inserting a <link rel="preload"> for the image so the browser starts fetching it from the <head> instead of waiting to discover it later in the page body — but the name change was made specifically because "priority" was vague about what it actually did.
<Image src="/hero.jpg" alt="Hero banner" width={1600} height={900} preload />
Use preload on the one image (or handful of images) that's actually your Largest Contentful Paint element — typically a hero image sitting above the fold. Don't reach for it reflexively on every image in a gallery; preloading everything defeats the purpose, since you're competing for the same limited early-loading bandwidth budget. The docs are explicit that you shouldn't combine preload with the loading prop or with a manually set fetchPriority — pick one signal, not several conflicting ones. In most cases where you'd have historically wanted "load this early but don't necessarily preload it in the head," loading="eager" or fetchPriority="high" is the more precise tool.
The plain loading prop still exists and does what you'd expect from the native HTML attribute:
<Image
src="/thumbnail.jpg"
alt="Thumbnail"
width={300}
height={200}
loading="lazy"
/>
lazy (the default) defers loading until the image is near the viewport; eager loads immediately regardless of position. Reserve eager for images you know are visible without scrolling but that, for whatever reason, don't warrant a full preload.
Placeholders: Making Loading Feel Smoother
The placeholder prop controls what shows while the real image is still in flight — "empty" (the default, nothing), "blur" (a blurred low-res version of the image), or a literal data:image/... URL you supply yourself.
<Image
src="/profile.png"
alt="Picture of the author"
width={500}
height={500}
placeholder="blur"
/>
For statically imported local images (jpg, png, webp, avif — not animated ones), the blurDataURL needed to power "blur" is generated automatically at build time, so this is essentially free. For remote or dynamically referenced images, you have to supply blurDataURL yourself, and it should be tiny — a 10px-or-smaller data URL is the recommendation. It's tempting to hand-craft something fancier, but a large blurDataURL inlines directly into your HTML and can itself become a performance cost if you're not careful, which somewhat defeats the point.
Configuring the Image Pipeline in next.config.js
Beyond per-image props, a good chunk of the optimization behavior is app-wide configuration under the images key. A few of these are worth understanding well before you hit them in production, because getting them wrong shows up as confusing 400 errors rather than obvious warnings.
qualities is the one that will genuinely surprise people upgrading to Next.js 16: it's now a required allow-list, not just a tuning knob. If you pass quality={80} on an <Image> but your qualities config doesn't include 80, Next.js silently coerces it to the closest allowed value (and logs a dev warning) — it doesn't error, but it also doesn't do what you asked. The default is qualities: [75].
// next.config.js
module.exports = {
images: {
qualities: [25, 50, 75, 100],
},
};
formats controls which modern formats Next.js will serve, matched against the browser's Accept header, in the order you list them:
module.exports = {
images: {
formats: ["image/avif", "image/webp"],
},
};
AVIF compresses roughly 20% smaller than WebP but takes about 50% longer to encode — so the very first request for a given size/format pairing is slower, though subsequent cached requests aren't. In practice I'd default to WebP alone unless you have a specific reason to chase the extra compression, since enabling both means Next.js caches both format variants separately, roughly doubling your optimized-image storage footprint.
deviceSizes and imageSizes define the breakpoint widths used to build srcset candidates — device widths for full-bleed images, and smaller image widths (which should all be narrower than your smallest deviceSize) for anything constrained by sizes to less than full viewport width. The defaults ([640, 750, 828, 1080, 1200, 1920, 2048, 3840] and [32, 48, 64, 96, 128, 256, 384] respectively) are sensible for most sites and rarely need touching unless your design has unusual breakpoints.
minimumCacheTTL sets how long an optimized image variant is cached (default 4 hours, expressed in seconds). There's currently no built-in mechanism to invalidate a cached optimized image on demand — if you need to force a refresh, you either change the src (e.g., append a version query param your remotePatterns.search also allows) or manually clear <distDir>/cache/images. Because of that limitation, the docs' own advice is worth repeating: prefer a static import over a long TTL wherever you can, since a statically imported image's filename is content-hashed and cached effectively forever with an immutable header — there's nothing to go stale.
Security-Sensitive Options You Should Understand Before Toggling
A few configuration flags exist specifically to unlock capabilities that carry real risk, and their names say so directly.
dangerouslyAllowSVG is off by default because SVG is not just an image format — it can embed script content, and rendering an untrusted SVG is functionally similar to rendering untrusted HTML. If you must serve SVGs through the optimizer, pair it with contentDispositionType: "attachment" (forces download rather than inline render) and a restrictive contentSecurityPolicy that blocks scripts. In practice, the simpler and usually sufficient answer is to just mark SVG sources unoptimized (which happens automatically whenever src ends in .svg) rather than opting into this at all — SVGs are already a resolution-independent vector format, so there's nothing meaningful for the raster optimizer to do to them anyway.
dangerouslyAllowLocalIP exists for self-hosted deployments on a private network that legitimately need to fetch images from other machines on that same network. Leaving this on in a normal deployment is a real SSRF risk — don't enable it unless you specifically understand why your setup needs it.
Common Mistakes Worth Knowing About Up Front
Treating next/image as a drop-in <img> replacement without setting up remotePatterns. This is the single most common "why does this work on localhost but 400 in production" report. Development is sometimes more forgiving here; always verify a production build against your actual next.config.js allow-list before shipping.
Forgetting that unoptimized and static export are linked. If you're exporting your site as static HTML with no server (output: "export"), the Image Optimization API — which requires a running server to resize images on demand — isn't available. You either set images: { unoptimized: true } and accept unoptimized originals, or configure a third-party image loader (Cloudinary, imgix, and similar services all publish Next.js loader functions) that can do the resizing itself, entirely outside of Next.js's own server.
Preloading everything. As covered above, preload is for your actual LCP candidate, not a general "make it load faster" switch. Preloading five images means competing for the same early bandwidth, which can make your real LCP element slower, not faster.
Missing sizes on responsively-laid-out images. Silently downloading a viewport-width image for something that only ever renders at a third of that size is one of the easiest wins to leave on the table, precisely because nothing errors — it just quietly wastes bandwidth.
Forgetting height: "auto" when overriding width via style. If you use the style prop to set a custom width on an image (rather than letting CSS layout handle it structurally), you need to pair it with height: "auto" or you'll distort the image's aspect ratio.
Advanced Patterns: Theme-Aware Images and Art Direction
Two patterns come up often enough in real projects that they're worth knowing before you need them, rather than reinventing them under deadline.
Swapping images for light/dark mode. The <Image> component doesn't have a built-in "dark mode variant" prop, but you don't need one — render both images and let a CSS media query decide which one is visible:
/* components/theme-image.module.css */
.imgDark {
display: none;
}
@media (prefers-color-scheme: dark) {
.imgLight {
display: none;
}
.imgDark {
display: unset;
}
}
// components/theme-image.tsx
import styles from "./theme-image.module.css";
import Image, { ImageProps } from "next/image";
type Props = Omit<ImageProps, "src" | "preload" | "loading"> & {
srcLight: string;
srcDark: string;
};
const ThemeImage = ({ srcLight, srcDark, ...rest }: Props) => (
<>
<Image {...rest} src={srcLight} className={styles.imgLight} />
<Image {...rest} src={srcDark} className={styles.imgDark} />
</>
);
Both images are present in the DOM; CSS just hides one. That's deliberate — the default loading="lazy" behavior means only the visible one actually downloads in most cases, but it also means you can't combine this with preload or loading="eager", since that would force both variants to load regardless of which one is shown. If you need the visible one to load with urgency, use the native fetchPriority="high" attribute instead, which sidesteps that conflict.
Serving genuinely different images per breakpoint (art direction). Swapping a light/dark variant of the same image is one thing; showing a differently cropped image for mobile versus desktop — a tighter portrait crop on small screens instead of just scaling down a wide landscape shot — needs a different tool: getImageProps(), which returns the props Next.js would have applied to an <img> without rendering the component itself, so you can wire them into a native <picture> element:
// app/page.tsx
import { getImageProps } from "next/image";
export default function Home() {
const common = { alt: "Art direction example", sizes: "100vw" };
const {
props: { srcSet: desktop },
} = getImageProps({
...common,
width: 1440,
height: 875,
src: "/desktop.jpg",
});
const {
props: { srcSet: mobile, ...rest },
} = getImageProps({
...common,
width: 750,
height: 1334,
src: "/mobile.jpg",
});
return (
<picture>
<source media="(min-width: 1000px)" srcSet={desktop} />
<source media="(min-width: 500px)" srcSet={mobile} />
<img {...rest} style={{ width: "100%", height: "auto" }} />
</picture>
);
}
This is the escape hatch for anything the <Image> component's own API doesn't directly model — you still get the resized, format-negotiated srcset values Next.js generates, you're just wiring them into markup you control rather than markup the component renders for you.
A Couple of Known Browser Quirks
Worth knowing so you don't chase a phantom bug: because next/image relies on native browser lazy loading, older browsers that predate wide support for it (pre-Safari 15.4) silently fall back to eager loading instead — not broken, just less lazy than you'd expect. Safari versions between 15 and 16.3 also render a visible gray border around a lazy-loaded image while it's still pending, which Safari 16.4 fixed; if you need to support that window, either force loading="eager" for above-the-fold images or apply a small CSS @supports workaround targeting WebKit specifically. Firefox, separately, shows a plain white flash on an unloaded image slot unless you're using a blur placeholder or have AVIF enabled. None of these are Next.js bugs — they're gaps in how consistently browsers have implemented the underlying platform features the component depends on — but they explain some otherwise-confusing visual differences you might notice while cross-browser testing.
Key Takeaways
| Scenario | What to do |
|---|---|
| Local image checked into the repo | Static import; width/height/blurDataURL inferred automatically |
| Local image with a dynamic filename | Dynamic import() inside an async Server Component |
| Remote image (CMS, S3, CDN) | Pass URL as src, supply width/height yourself, allow-list the host in remotePatterns |
| Unknown aspect ratio | fill on a positioned parent, with objectFit set explicitly |
| Responsive / non-full-width image | Always set sizes to match the actual rendered width |
| Above-the-fold hero / actual LCP element | preload (not the deprecated priority) |
| Everything else offscreen | Leave loading="lazy" (the default) alone |
| Smoother perceived loading | placeholder="blur" (automatic for static imports) |
| SVGs | Prefer unoptimized over dangerouslyAllowSVG |
Static export (output: "export") | images.unoptimized: true or a third-party loader |
The through-line across all of this is that next/image isn't really "the image tag but fancier" — it's a small, opinionated performance system with real security and caching implications baked into its configuration surface. Get the fundamentals right (correct dimensions or fill, an accurate sizes, a narrow remotePatterns allow-list, preload reserved for your actual LCP element) and you get most of modern image-performance best practice without writing a line of it yourself.


