
next/image is one of those components that does a lot of work for you — responsive srcset, lazy loading, format conversion, layout-shift prevention — if you use it correctly. Most of the "Next.js images are broken" complaints I see aren't the component's fault. They're four or five recurring mistakes.
I ran into every one of these building my portfolio (Folio, with Sanity-driven content) and the Next.js frontends for Sula and Aurora Organics. CMS images are where these pitfalls bite hardest, because you lose all the free magic you get from local static imports. So let me walk through the ones that actually cost me Lighthouse points, in roughly the order they'll cost you yours.
width and height (hello, layout shift)This is the classic. You render an image without dimensions, the browser doesn't know how tall it'll be, so it reserves zero space. Then the image loads, shoves everything down, and your Cumulative Layout Shift score tanks.
The fix is non-negotiable: every image needs dimensions. With local images you get them for free:
import hero from "@/assets/hero.jpg";
// Next infers width, height, AND a blur placeholder. Zero config.
<Image src={hero} alt="Dashboard preview" placeholder="blur" />
With remote/CMS images you have to declare them yourself:
<Image
src={project.coverUrl}
alt={project.title}
width={1200}
height={630}
/>
If you don't know the real dimensions ahead of time, use fill instead — but that comes with its own trap, which is next.
fill without a sized, positioned parentfill tells the image to expand to its container. The catch: the container has to actually have a size and position: relative (or absolute/fixed). Forget that and the image either collapses to nothing or spills out of the layout.
// ❌ Parent has no dimensions — image collapses or overflows
<div>
<Image src={url} alt="..." fill />
</div>
// ✅ Parent is positioned and sized; image fills it cleanly
<div className="relative aspect-video w-full">
<Image src={url} alt="..." fill className="object-cover" />
</div>
aspect-ratio (or Tailwind's aspect-*) is your friend here — it gives the container a stable shape before the image arrives, which also kills layout shift.
sizes — the blurry-image culpritThis is the one almost everyone gets wrong, and it's usually the reason your images look soft or your page is heavier than it should be.
When you use fill or a responsive layout, Next.js generates a srcset with multiple widths. The sizes prop tells the browser how wide the image will actually render at each breakpoint, so it can pick the right source. Leave it out and the browser assumes 100vw — full viewport width — for every image.
That has two failure modes. A thumbnail in a 4-column grid will download a massive full-width file (wasted bandwidth). And in some layouts you end up serving a source that's the wrong size for the slot, so it gets scaled and looks blurry on high-DPI screens.
// A card image in a 3-column grid on desktop, 1-column on mobile
<Image
src={url}
alt="..."
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
Rule of thumb: sizes should reflect the image's rendered width at each breakpoint, not the viewport. A grid image should almost never claim 100vw.
By default, every non-priority image is lazy-loaded. Great for the footer logo, terrible for the hero banner — because your Largest Contentful Paint element shouldn't wait until it scrolls into view (it's already in view).
If you're on Next.js 15 or earlier, mark it with priority:
<Image src={hero} alt="..." priority />
If you've moved to Next.js 16, priority is deprecated in favor of preload — same idea, clearer name:
<Image src={hero} alt="..." preload />
One caveat that trips people up: don't slap this on everything. If every image is "high priority," none of them are, and you'll actually hurt your LCP by flooding the network early. Run Lighthouse, confirm which element is actually the LCP, and prioritize that one.
Local static imports generate a blur placeholder automatically. CMS and CDN images don't — so unless you do something, your users stare at empty boxes that suddenly pop into full images. It feels janky, and it's a real perceived-performance hit.
You need to generate a blurDataURL yourself. For one-offs, a library like plaiceholder produces a tiny base64 string server-side:
import { getPlaiceholder } from "plaiceholder";
const buffer = await fetch(project.coverUrl).then((r) => r.arrayBuffer());
const { base64 } = await getPlaiceholder(Buffer.from(buffer));// then:
<Image src={project.coverUrl} placeholder="blur" blurDataURL={base64} {...dims} />
Even better if your CMS gives you one for free. Sanity, for instance, ships a low-quality image placeholder in its image metadata (metadata.lqip) — query for it once and you never have to compute a blur at request time. That's exactly what I do on Folio, and it means the placeholder is already in the payload before the page even renders.
remotePatterns (the "works locally, breaks in prod" one)If you serve remote images, Next.js refuses to optimize any host you haven't explicitly allowed. The old domains array is deprecated — use remotePatterns, and scope it tightly rather than wildcarding the whole internet:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.sanity.io",
pathname: "/images/your-project-id/**",
},
],
formats: ["image/avif", "image/webp"], // AVIF first for ~20% smaller files
},
};
Two bonuses in there: enabling AVIF alongside WebP squeezes meaningfully smaller files at scale, and a tight pathname keeps your optimizer from being abused as an open image proxy.
Before you ship, scan every <Image> for:
width/height, or fill inside a sized, positioned, aspect-ratio'd parent.sizes — set on every responsive image, matching its real rendered width.priority (or preload on Next 16) on the hero, and only the hero.blurDataURL for remote images; let static imports handle their own.remotePatterns scoped tight, AVIF/WebP enabled.alt — never skip it. Free accessibility, free SEO.None of this is exotic. It's just a handful of props that the component can't infer for you when the image lives on a remote server. Get them right and next/image quietly does its job — sharp images, zero layout shift, and a Lighthouse score that stops embarrassing you.
Wrestling with a slow Next.js build? Performance work is half of what I do. Say hi.