Next.js Image Component and Image Optimization in Production
Your Lighthouse score tanks on the "Largest Contentful Paint" metric because your hero image is a 4MB PNG served at its native 3000px width to a 400px-wide mobile viewport. This is one of the most common — and most fixable — performance problems on the web, and Next.js's Image component exists specifically to fix it without you hand-rolling responsive srcset logic.
The basic swap
// Before: no optimization, no lazy loading, no responsive sizing
<img src="/hero.png" alt="Product hero" />
// After: automatic resizing, format conversion, lazy loading
import Image from "next/image";
<Image
src="/hero.png"
alt="Product hero"
width={1200}
height={630}
priority
/>
width/height aren't just for sizing — they let the browser reserve layout space before the image loads, preventing cumulative layout shift (a Core Web Vitals metric Google actually ranks on). priority disables lazy loading for above-the-fold images like a hero, since lazy-loading your most important image is counterproductive.
What's actually happening under the hood
Next.js runs images through an optimization API route (/_next/image) that resizes on demand, converts to modern formats (WebP or AVIF) when the browser supports them, and caches the result. You get this without configuring an image CDN yourself — though for high-traffic production sites, pairing it with a real CDN in front of Vercel or your host is still worth it.
Remote images need explicit permission
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
{ protocol: "https", hostname: "cdn.yoursite.com" },
],
},
};
This is a security boundary, not busywork — without it, Next.js refuses to optimize images from arbitrary external domains, since blindly fetching and processing any URL a request supplies is an SSRF risk.
Fill mode for unknown aspect ratios
When you don't know the image's dimensions ahead of time (user uploads, CMS content):
<div style={{ position: "relative", width: "100%", height: "400px" }}>
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
fill makes the image absolutely positioned to cover its parent, which needs position: relative and an explicit size. The sizes attribute tells the browser which image size to request at which viewport width — get this wrong and you'll either over-fetch (full-size image on mobile) or under-fetch (blurry image on desktop).
Common mistakes
- Omitting
sizeson responsive images (fillor images that change width across breakpoints) — without it, the browser assumes the image is 100vw and downloads a needlessly large file on desktop. - Using
priorityon every image "just in case" — this defeats lazy loading sitewide and can hurt LCP by competing for bandwidth with the actual above-the-fold content. - Serving user-uploaded images directly without adding their domain to
remotePatterns, then wondering why they silently fail to render. - Hardcoding
width/heightthat doesn't match the image's real aspect ratio, causing visible distortion or letterboxing.
What I'd actually use
next/image for everything except truly tiny decorative icons (where an inline SVG is simpler and avoids a network request entirely). Set priority on exactly one image per page — the actual LCP element — and nothing else. For a CMS-driven blog, generate blurDataURL placeholders at build/fetch time so images fade in smoothly instead of popping in, which meaningfully improves perceived performance even when raw load time is unchanged.
Next steps
Run Lighthouse against your current production build and look for "Properly size images" and "Serve images in next-gen formats" warnings — nearly every flagged image is fixed by swapping a bare <img> for next/image with correct sizes.