Using next/image the way it wants to be used — lessons from code review
I used to treat next/image like a fancier <img> tag — swap the import, get free optimization, move on. Then a round of code reviews sent me back to the docs with a different question: "why does the component keep asking me for things I don't want to give it?" The width and height it demands, the sizes string that looked like noise, the fill prop I didn't understand. Each one is the component telling me how it wants to be used.
Overview: what next/image actually does for you
Before digging into the props, it's worth being precise about what you get for free. Swapping <img> for <Image> turns on four optimizations that most teams would otherwise wire up by hand:
All four behaviors ride on the same three-stage pipeline — source → optimize → deliver — that runs the first time each image is requested:
Keep that pipeline in mind as you read on: the props aren't decorative metadata, they're instructions to the optimize stage about which asset to produce and to the deliver stage about how the browser should treat it.
Lesson 1 — width & height: the numbers that aren't about size
Cumulative Layout Shift happens when the browser doesn't know how tall an image will be — it lays the page out at height: 0, then shoves everything below down the moment the image lands. Same card on both sides, slow connection, watch the left one jump:
The right side avoids the shift because the width and height props do a very specific job, and it isn't the one their names suggest:
That's why the numbers don't have to match the pixel size on the page. 44×44, 100×100, and 800×800 all behave the same for a square image — the ratio is all the browser cares about for reserving space.
But what actually decides the rendered size?
width and height do not determine the rendered size of the image, which is controlled by CSS. Once the ratio is locked in, whichever dimension CSS pins down, the browser derives the other from it.
- CSS sets
width: 440px? → Browser derivesheight: 440pxfrom the 1:1 ratio. - No CSS at all? → The declared
width/heightattributes become the rendered size.
So the layout box always ends up the right shape — whether you write CSS or not. What CSS can't fix, though, is the pixels inside that box:
If you don't know the height and width the image should be reserved at, Next.js recommends using the fill property instead.
Lesson 2 — fill: when the parent decides the size
Sometimes you genuinely can't declare width and height — a hero that stretches to the viewport, a card cover defined by a grid. That's what fill is for:
<div className="relative aspect-video w-full">
<Image
src="/cover.png"
alt="Cover"
fill
sizes="100vw"
className="object-cover"
/>
</div>
With fill, Next.js positions the image with position: absolute; inset: 0 so it stretches to fill the nearest positioned ancestor. If no ancestor has a position other than static, the image walks all the way up and anchors to <body> instead:
Two consequences fall out of this:
- The parent has to reserve the layout box. The image no longer carries an intrinsic size. Give the parent a fixed
height, or — more commonly — a width from layout (w-full, a grid cell) plus anaspect-ratioso the browser can derive the height. sizesbecomes mandatory. Withoutwidthon the image, the optimizer has no way to know how wide the slot will render at request time — the parent's width depends on viewport and breakpoint. Lesson 3 covers what to write.
The two points sound similar but talk about different actors at different times:
| Layout box (1) | sizes (2) | |
|---|---|---|
| Who needs the width? | Browser | Next.js image optimizer |
| When? | Runtime, after CSS resolves | Request time, on the server |
| Can it see the parent? | Yes — w-full is already 600px on this device | No — viewport and breakpoint are unknown |
Lesson 3 — sizes: telling the optimizer how wide the slot really is
Whenever the image is fluid — fill, w-full, % widths — the optimizer needs a hint about the rendered width so it can pick the right entry from the srcset. That hint is sizes. Omit it and Next.js falls back to 100vw, shipping the largest asset in the srcset every single time. Typical waste on a card grid: 50–70% of image payload.
Drag the viewport in the evaluator below to see how a single sizes string resolves at each breakpoint — the first matching media query wins, and the picked width × DPR is what the optimizer ships:
The rule of thumb: ask "does this image's rendered width change based on viewport or layout?" If yes (fluid), add sizes. If no (truly fixed px), skip it.
| Case | Need sizes? |
|---|---|
Uses fill | ✅ Always |
Uses width/height + CSS w-full or % | ✅ Yes |
Uses width/height at a fixed px (logo, icon, footer avatar) | ❌ No |
How to calculate the viewport %
The mental question sizes answers is: "how much of the viewport does the image's parent occupy at each breakpoint?" Since the image fills its parent (either literally with fill, or effectively with w-full), image width = parent width. Your job is to express that as a percentage of the viewport.
A three-step process:
- Locate the parent. How many columns is it in? Is there a
max-w-*container above it? - Convert to viewport % at each breakpoint.
w-fullinside a 4-col grid =25vw. Inside a 2-col grid =50vw. - Write the string smallest breakpoint first.
sizesreads left to right and the first matching media query wins.
Quick conversions:
| Parent width | sizes value |
|---|---|
w-full (full viewport) | 100vw |
w-1/2, 2-column grid | 50vw |
| 3-column grid | 33vw |
| 4-column grid | 25vw |
Putting them together for a responsive grid:
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
<div className="relative aspect-video">
<Image
src="/thumb.png"
alt="Thumb"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
</div>
</div>
- Mobile (
<768px): 1 column → image =100vw - Tablet (
768–1024px): 2 columns → image =50vw - Desktop (
>1024px): 4 columns → image =25vw
Lesson 4 — loading & preload: when the image loads
So far every prop we've discussed answers "how big?" But there's a second question the browser needs to answer for every image: "how urgent?" That's what loading and preload control — and it's the difference between a fast LCP and a hero image that shows up half a second late.
loading — lazy vs eager
loading="lazy" is the default. The browser defers loading the image until it's near the viewport. Perfect for anything below the fold — a card that renders 3 screens down doesn't need to compete with your hero for bandwidth.
<Image src="/blog-thumb.png" width={400} height={300} alt="Post thumbnail" />
{/* lazy by default — won't load until user scrolls near it */}
loading="eager" forces the image to load immediately, even if it's offscreen. Use only when you know the image is above the fold and you don't want to wait for the browser's lazy-loading heuristics to decide.
<Image
src="/hero.jpg"
width={1600}
height={900}
loading="eager"
alt="Hero"
/>
preload — Next.js 16 replaces priority
Prior to Next.js 16, you'd write priority on your LCP image. In Next.js 16+, that prop is deprecated in favor of preload. Same idea, clearer name:
{/* Next.js 15 and earlier */}
<Image src="/hero.jpg" priority ... />
{/* Next.js 16+ */}
<Image src="/hero.jpg" preload ... />
preload={true} injects <link rel="preload" as="image" ...> into the document <head>, so the browser starts fetching the image before it even encounters the <img> tag in the body. On a hero image, this shaves hundreds of milliseconds off LCP.
preload when- The image is your LCP element (usually the hero or the first large visible image).
- It's above the fold on initial load.
- There's exactly one such image per page.
preload when- Multiple candidate LCP images exist (e.g. a carousel — the browser can't preload all of them; you'll waste bandwidth).
- The image is below the fold — preloading a hidden image steals bandwidth from more urgent resources.
Putting timing together
Three images on the same page, three timing decisions — this is the part most codebases skip. Every <Image> gets treated the same, so the browser loads everything at once and nothing gets prioritized:
Recap
next/image exposes three levers, and getting all three right is what turns it from "just works" into "PageSpeed 100":
| Lever | Prop(s) | Get it wrong and you get… |
|---|---|---|
| Layout (CLS) | width + height, or parent size when fill | Content jumps as images load |
| Quality | width matches rendered size, sizes when fluid | Blurry upscales or oversized downloads |
| Timing | loading, preload (was priority) | Slow LCP, wasted bandwidth |
The one-line rules:
- Declare
widthandheightat the image's actual rendered 1x size, in the source file's true aspect ratio. - Add
sizeswhenever the image is fluid (usesfill, orw-full/%in CSS). - Add
preloadto exactly one image per page — your LCP hero. Everything above the fold that isn't the LCP getsloading="eager". Everything else stays on the defaultloading="lazy".
Follow those three rules across your codebase and you'll typically see 30–70% reduction in image payload, LCP improvements of 200–800ms, and CLS scores drop to near zero — without changing a single image file.