We see the same thing constantly when reviewing Next.js projects: someone dropped in next/image, patted themselves on the back for 'optimizing images', and called it done. The Lighthouse score is still 68. The LCP is still 3.4 seconds. The images are still making users wait.
Next.js Image does a lot automatically — WebP conversion, lazy loading, preventing layout shift. But 'automatic' doesn't mean 'optimal'. There are a handful of props and patterns that unlock the real performance gains, and most developers either don't know about them or skip them because the app 'works fine'.
Let's fix that.
priority={true} — The Most Skipped Prop
By default, every next/image is lazy loaded. That means the browser only starts downloading the image after it appears in (or near) the viewport. For images below the fold, this is exactly what you want. For your hero image, your above-the-fold product photo, your LCP element — this is a disaster.
The fix is one prop. And yet we've seen production apps with hero images shipping without it.
// Wrong — hero image with default lazy loading
<Image
src="/hero.jpg"
alt="Product hero"
width={1200}
height={600}
/>
// Right — preloaded, not lazy loaded
<Image
src="/hero.jpg"
alt="Product hero"
width={1200}
height={600}
priority
/>When you add priority, Next.js adds a <link rel="preload"> to the document head for that image. The browser starts fetching it immediately, in parallel with the rest of the page. This is the single most impactful change you can make for LCP on landing pages. We've seen it knock 800ms off LCP on real apps just by adding this one prop.
Rule of thumb: any image visible without scrolling on page load should have priority. On most pages that's 1-2 images max. If you're adding it to 10 images, you've missed the point — you're just prefetching everything, which defeats the purpose.
sizes — The Prop That Actually Controls What Gets Downloaded
This one requires understanding what Next.js does under the hood. When you use next/image, Next.js generates multiple resized versions of your image and serves them via its built-in image optimization API (or a CDN like Vercel's). The browser then picks the right size using the srcset attribute.
The problem: without a sizes prop, Next.js assumes your image takes up 100% of the viewport width. So on mobile, a user gets a full 390px-wide image even if your layout only shows it at 150px wide. You're making them download 2-3x the data they need.
// Bad — Next.js thinks this image is always full-width
// Mobile users download a 390px image even if it's only 150px wide
<Image
src="/product-thumbnail.jpg"
alt="Product"
width={300}
height={300}
/>
// Good — browser knows the actual rendered size at each breakpoint
<Image
src="/product-thumbnail.jpg"
alt="Product"
width={300}
height={300}
sizes="(max-width: 768px) 150px, (max-width: 1200px) 250px, 300px"
/>
// For full-width hero images:
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="100vw"
priority
/>The sizes value uses the same media query syntax as CSS. Left to right, first match wins. So `(max-width: 768px) 150px` means 'on screens 768px wide or less, this image renders at 150px'. The final value (no media query) is the default for larger screens.
Getting sizes right can reduce image payload by 40-60% on mobile. That's not a small thing. On a product listing page with 12 thumbnails, you might be shipping 3MB of images when 800KB would do.
fill vs width/height — When to Use What
The fill prop is for when you don't know the image dimensions ahead of time, or when you want the image to fill a container responsively. It absolutely positions the image inside its parent and stretches it to fill. The parent needs position: relative (or absolute or fixed).
// fill is great for image cards where the container defines the size
<div className="relative aspect-video w-full overflow-hidden rounded-lg">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
// width/height is better when you know the exact dimensions
// and the image doesn't need to be responsive
<Image
src="/avatar.jpg"
alt="User avatar"
width={48}
height={48}
className="rounded-full"
/>One mistake we see with fill: people forget object-fit. Without it, the image stretches and distorts. Add className="object-cover" for images where you want to crop to fill (most cover images), or className="object-contain" when the full image must be visible.
Also: if you're using fill, the sizes prop becomes even more important. There's no explicit width/height for Next.js to reference, so without sizes, it really will assume 100vw for everything.
quality — Stop Defaulting to 75
Next.js defaults to quality={75}. This is usually fine for photographs. But it's not a universal answer.
- For hero images where quality matters more than file size, try quality={85} or even quality={90}
- For thumbnails or images that are mostly decoration, quality={60} or quality={65} is often indistinguishable and saves meaningful bytes
- For images with text or sharp edges (UI screenshots, diagrams), WebP at 75 can look blurry — bump it up
- For icons and simple graphics, use SVGs instead of Image entirely — no quality tradeoff at all
There's no magic number. The right approach is to open your images at various quality levels and actually look at them. We know, revolutionary advice. But you'd be surprised how many people ship images at default quality without ever checking if they look good or if they're unnecessarily heavy.
placeholder and blurDataURL — Perceived Performance Matters
Even with everything configured correctly, images still take time to load. The default behavior is nothing — white space until the image arrives. You can do better.
// For local images — Next.js generates the blur automatically
import heroImage from '@/public/hero.jpg'
<Image
src={heroImage}
alt="Hero"
placeholder="blur"
priority
/>
// For remote images — you need to provide blurDataURL yourself
// Generate a tiny base64 placeholder (using a tool or at build time)
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>
<animate attributeName="x1" from="-1" to="1" dur="1s" repeatCount="indefinite" />
</defs>
<rect width="${w}" height="${h}" fill="url(#g)" />
</svg>`
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str)
// Then use it:
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={400}
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,${toBase64(shimmer(400, 400))}`}
/>For local images (imported directly), Next.js automatically generates a tiny blurred placeholder at build time and you just need placeholder="blur". For remote images from a CMS or database, you'll need to either generate a blur hash at build time or use a shimmer effect like above.
The shimmer approach is our go-to for dynamic content. It looks like a skeleton loader, is pure SVG (no extra request), and gives users something to look at while the real image loads. Perceived performance is real performance.
Remote Domains Config — And the remotePatterns Gotcha
If you're loading images from external sources (S3, Cloudinary, your CMS, user uploads), you need to whitelist the domains in next.config.js. The old domains config is deprecated — use remotePatterns.
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'your-bucket.s3.eu-central-1.amazonaws.com',
pathname: '/uploads/**',
},
{
protocol: 'https',
hostname: '*.cloudinary.com',
},
],
// Optional: cache optimized images longer (default is 60 seconds)
minimumCacheTTL: 60 * 60 * 24 * 7, // 1 week
},
}
export default configThe minimumCacheTTL setting is easy to miss. By default, Next.js caches optimized images for 60 seconds. If you're serving static assets that don't change, bumping this to a week or more means the optimization API only runs once per image, not on every cache miss. On high-traffic sites this matters — image optimization is CPU-intensive.
If you're self-hosting (not on Vercel), image optimization runs on your server. Every optimized image request is your CPU doing work. Either configure aggressive caching, use a CDN in front, or consider a dedicated image CDN like Cloudinary or imgix and just serve the already-optimized URL.
The Full Picture: A Properly Configured Image Component
Here's what a well-configured hero image component looks like when you put it all together:
import Image from 'next/image'
interface HeroImageProps {
src: string
alt: string
}
export function HeroImage({ src, alt }: HeroImageProps) {
return (
<div className="relative aspect-[16/9] w-full overflow-hidden">
<Image
src={src}
alt={alt}
fill
priority // preload, not lazy
quality={85} // slightly higher for hero
sizes="100vw" // full width image
className="object-cover"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
</div>
)
}
// And a product thumbnail with different settings:
interface ThumbnailProps {
src: string
alt: string
}
export function ProductThumbnail({ src, alt }: ThumbnailProps) {
return (
<div className="relative aspect-square overflow-hidden rounded-md">
<Image
src={src}
alt={alt}
fill
// no priority — these are below the fold
quality={70} // smaller files for thumbnails
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
)
}Notice how the two components have different quality settings, different sizes, and only the hero has priority. This is intentional. One size doesn't fit all — treat each image role differently.
What This Looks Like in Practice
We went through this exercise on a client's e-commerce site last year. Starting state: all images using next/image with default settings, no sizes prop, no priority on anything. LCP was hovering around 3.8 seconds on mobile.
Changes made: added priority to the hero image, added sizes to all product thumbnails, bumped minimumCacheTTL to a week, added shimmer placeholders for the product grid. Total dev time: about 90 minutes.
Result: LCP dropped to 1.9 seconds on mobile. Lighthouse performance score went from 61 to 84. The client thought we'd done something magical. We'd just read the docs more carefully than whoever built the original site.
All of this is baked into our peal.dev templates — the Image components come pre-configured with the right defaults so you're not starting from scratch every time and discovering these gotchas in production.
Quick Checklist Before Shipping
- Hero / above-the-fold images have priority={true}
- All images have a sizes prop that reflects actual rendered size, not 100vw
- Images using fill have object-cover or object-contain
- Local images use placeholder="blur" for free blur placeholders
- Remote images have a blurDataURL or shimmer for perceived loading
- next.config.ts uses remotePatterns (not the deprecated domains)
- minimumCacheTTL is set if you're self-hosting
- quality is tuned per image role, not left at default 75 for everything
Next.js Image is not a magic performance button. It's a toolkit. You still have to use it correctly. The automatic benefits (WebP conversion, lazy loading, layout shift prevention) are real but they're the floor, not the ceiling.
