We see the same pattern constantly: developers switch from a plain <img> tag to next/image, pat themselves on the back, and call it done. Lighthouse still shows a 3.2s LCP. The images are still the bottleneck. And they have no idea why, because they did the thing everyone said to do.
The thing is, next/image out of the box is not a silver bullet. It's more like a sports car that ships with the handbrake on. You have to know which levers to pull. We've spent embarrassing amounts of time debugging image performance across a dozen projects, and here's what actually moves the needle.
priority={true} Is Not Optional for Above-the-Fold Images
This is the single most impactful thing you're probably skipping. By default, next/image lazy loads everything. That's great for images below the fold — don't load what the user can't see yet. But your hero image, your product screenshot at the top of the page, your avatar in the navbar? Those need to load immediately. Lazy loading them actively hurts your LCP.
The fix is one prop, but the mental model matters: ask yourself 'would this image be visible when the page first renders on a slow 3G connection?' If yes, it gets priority. Simple rule, big impact.
// Bad — hero image that lazy loads. Your LCP will be terrible.
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
/>
// Good — preloads this image, signals browser to fetch it early
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority
/>
// Rule of thumb: only the first 1-2 images on the page get priority.
// Marking everything as priority defeats the purpose entirely.When you set priority, Next.js adds a <link rel="preload"> in the document head for that image. The browser fetches it before it even starts parsing the rest of the page. The difference on a real mobile connection is often 600-900ms. That's the difference between a good and a poor LCP score.
sizes — The Prop That Actually Controls How Much You Download
Here's a scenario we've seen in production more times than we'd like to admit: a card grid where each card is roughly 300px wide on desktop. The image inside is getting served at 1200px wide because nobody set sizes. On a mobile device with a 2x screen, that's a 600px image being served as 1200px. You're shipping 4x the pixels that will ever be displayed.
The sizes prop tells the browser (and Next.js image optimization) how wide the image will actually render at different viewport sizes. Next.js uses this to generate the srcset and pick the right variant. If you don't set it, Next.js assumes the image is 100vw — full viewport width — which generates unnecessarily large images.
// Without sizes — Next.js assumes 100vw and serves massive images
<Image
src="/product.jpg"
alt="Product"
width={300}
height={300}
/>
// With sizes — browser downloads the right sized image
<Image
src="/product.jpg"
alt="Product"
width={300}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 300px"
/>
// For a full-width hero:
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="100vw"
priority
/>
// For a sidebar thumbnail:
<Image
src="/thumb.jpg"
alt="Thumbnail"
width={80}
height={80}
sizes="80px"
/>Reading those sizes strings feels weird at first but it's just CSS media query syntax: from the right side, 'this is 300px wide by default, 50% viewport on medium screens, full width on mobile'. The browser reads right to left. Get this right and you can cut image payload by 50-70% for users on smaller screens.
fill vs. width/height — Pick the Right Tool
The fill prop trips people up constantly. It makes the image expand to fill its container, like object-fit: cover in CSS. This is perfect for unknown aspect ratios — user profile pictures, CMS-managed content, any situation where you can't guarantee the image dimensions. But it requires the parent element to have position: relative (or absolute/fixed) and explicit dimensions. Forget that, and you get a 0x0 image with no errors.
// fill requires a positioned parent with explicit size
<div className="relative h-64 w-full overflow-hidden rounded-lg">
<Image
src={post.coverImage}
alt={post.title}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
// Use width/height when you know the exact dimensions
// This is better for performance — no layout calculation needed
<Image
src="/logo.svg"
alt="Company logo"
width={120}
height={40}
/>
// For responsive images where you know the aspect ratio,
// width/height + CSS is often cleaner than fill
<Image
src="/article-hero.jpg"
alt="Article hero"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 80vw"
className="w-full h-auto"
/>The width/height props don't lock the image into those exact pixel dimensions on screen — they just communicate the aspect ratio to the browser so it can reserve space before the image loads (preventing layout shift). Then CSS handles the actual display size. This is why you can set width={1200} height={630} and then use className="w-full h-auto" to make it responsive.
Configuring remotePatterns for External Images (Properly)
If you're loading images from an external source — S3, Cloudinary, a CMS, user avatars from whatever OAuth provider — you need to whitelist the domain in next.config.js. Most people know this. What most people don't know is that the old domains config is deprecated and the new remotePatterns is both more secure and more flexible.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// S3 bucket — lock it down to your specific bucket
{
protocol: 'https',
hostname: 'your-bucket.s3.eu-central-1.amazonaws.com',
pathname: '/uploads/**',
},
// Cloudinary — wildcard for your cloud name
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/your-cloud-name/**',
},
// GitHub avatars for OAuth
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
// Google profile pictures
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
},
],
// Optionally define your own device sizes for the srcset
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
// Image formats — avif is smaller but slower to encode
formats: ['image/avif', 'image/webp'],
},
}
export default nextConfigThe AVIF format note is worth expanding. AVIF files are typically 30-50% smaller than WebP, which is already 25-35% smaller than JPEG. But encoding AVIF is CPU-intensive. On Vercel this isn't your problem — they handle it. If you're self-hosting and your server CPU is shared or limited, having avif first in the formats array means every image optimization request will burn more CPU. We learned this running Next.js on a €5 Hetzner VPS. Swap the order to ['image/webp', 'image/avif'] if you're seeing slow first loads of optimized images.
The placeholder Prop — Stop Showing Empty Space
While your image loads, next/image shows nothing by default. Just empty white space. The placeholder prop lets you either show a blur-up effect (like Medium does) or a static color. Both are better UX than a white rectangle appearing to grow into an image.
// For local images — Next.js auto-generates the blurDataURL at build time
import heroImage from '@/public/hero.jpg'
<Image
src={heroImage}
alt="Hero"
priority
placeholder="blur"
// blurDataURL is automatic for static imports
/>
// For dynamic/remote images — you need to provide blurDataURL yourself
// Generate a base64 encoded tiny placeholder
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="g">
<stop stop-color="#f0f0f0" offset="20%" />
<stop stop-color="#e0e0e0" offset="50%" />
<stop stop-color="#f0f0f0" offset="70%" />
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="#f0f0f0" />
</svg>`
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str)
// Use it
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={420}
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,${toBase64(shimmer(800, 420))}`}
/>The shimmer effect with an SVG is a clean trick — gives you the skeleton loading look without any additional dependencies. For local images (imported directly with import heroImg from './hero.jpg'), Next.js does the whole thing automatically at build time. Remote images need manual blurDataURL because Next.js can't fetch and process them at build time in all scenarios.
When next/image Is Overkill (Yes, Really)
We'll say the quiet part out loud: sometimes you don't need next/image. For very small images (icons under 20px, tiny decorative elements), SVGs, or images that genuinely never change and you've already optimized manually — the overhead of routing through the image optimization API adds latency for zero benefit. A 1KB SVG logo doesn't need WebP conversion.
- SVG files: Use <img> or inline them. next/image doesn't optimize SVGs anyway.
- Small icons that could be icon components or CSS: Don't ship them as image files at all.
- Images you've already optimized and serve from a CDN with proper headers: The double-optimization adds latency.
- Images in emails: next/image is for browser rendering, not email clients.
- Open Graph images: Generated server-side, not rendered in the browser.
next/image is a tool, not a religion. Use it for the 80% of cases it's perfect for. Know when the plain <img> tag is the right call.
Practical Checklist Before You Ship
Run through this before you deploy anything image-heavy. We keep this as a comment in our base layout components as a reminder.
- Hero/above-the-fold images have priority prop set
- All images have a meaningful sizes prop (not just relying on the 100vw default)
- fill images have a positioned parent with explicit dimensions
- Static imports used where possible (auto-generates blurDataURL, enables build-time optimization hints)
- remotePatterns configured with specific paths, not wildcards for entire domains
- placeholder='blur' on large images for better perceived performance
- deviceSizes in next.config.ts matches your actual breakpoints
If you want a starting point that has all of this pre-configured — the right remotePatterns setup, image components with proper sizes props, the shimmer utility already in place — our templates on peal.dev come with this stuff wired up from day one. The image configuration alone has saved us from several 'why is my LCP bad' debugging sessions.
The bottom line: next/image is genuinely excellent when you understand what the props actually do. Most of the complaints about Next.js image performance come from people who did the minimum (swap the tag, add width and height) and stopped there. The real gains are in priority for above-fold images, accurate sizes so the browser requests the right dimensions, and placeholder for perceived performance. Those three changes will get you from mediocre to good on almost any project.
