We've reviewed a lot of Next.js codebases — our own, clients', templates we've built for peal.dev — and there's a pattern that shows up constantly: people swap `<img>` for `<Image>` from `next/image`, pat themselves on the back, and call it done. Lazy loading, nice. But they're leaving 70% of the performance gains on the table.
This post is about the stuff that actually moves your Lighthouse score and your Core Web Vitals. Not the basics — you already know those. The things we had to learn by shipping broken pages at 2am and then obsessively reading the Next.js source code to figure out why.
Priority Loading: You're Probably Doing This Wrong
The single biggest LCP (Largest Contentful Paint) mistake we see is not setting `priority` on the hero image. By default, Next.js Image lazy loads everything. That's great for images below the fold. It's terrible for the biggest image your user sees the moment the page loads.
When you add `priority`, Next.js injects a `<link rel="preload">` tag in the document head, fetches the image eagerly, and skips the Intersection Observer entirely. The difference on a hero image can be 400-800ms on LCP. We've seen it go from 3.2s to 2.1s on a real product page just by adding one prop.
// Wrong — your hero image loads lazily like everything else
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
/>
// Right — preloaded, no lazy loading, LCP-friendly
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>Rule of thumb: any image visible without scrolling on the initial load should have priority={true}. Usually that's one image per page, sometimes two.
sizes — The Prop Everyone Ignores
This one genuinely surprised us when we first dug into it. The `sizes` prop doesn't just describe the image — it tells the browser which source to download from the automatically-generated srcset. Get it wrong and your mobile users are downloading a 1200px image to display at 390px. That's 4-6x the bytes they needed.
Next.js generates a srcset based on the `deviceSizes` and `imageSizes` config in next.config.js. But without `sizes`, the browser assumes the image is full viewport width. It will pick the largest source that fits the screen density. On a 2x Retina phone, that's still huge.
// Without sizes — browser downloads full-width image on every device
<Image
src="/product.jpg"
alt="Product"
width={600}
height={400}
/>
// With sizes — browser picks the right srcset entry for the layout
<Image
src="/product.jpg"
alt="Product"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 600px"
/>
// For a card grid that's full width on mobile, half on tablet, 33% on desktop
<Image
src="/card-thumbnail.jpg"
alt="Card"
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>The `sizes` value is a media query string that maps viewport widths to image widths. Read it right to left: the last value is the default, and the browser applies the first matching condition. It's the same syntax as the native HTML `sizes` attribute — Next.js just passes it through.
fill vs width/height — Pick the Right Tool
There are two ways to use the Image component: explicit `width` and `height` props, or `fill` mode where the image fills its parent container. We see people use `fill` everywhere because it's easier (no need to know dimensions), but it comes with a gotcha: the parent must be `position: relative` (or `absolute` or `fixed`), and you need `sizes` or you're back to the problem above.
// fill mode — image fills the container, great for aspect-ratio boxes
// Parent MUST have position: relative and explicit dimensions
<div className="relative aspect-video w-full">
<Image
src="/thumbnail.jpg"
alt="Video thumbnail"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
// width/height mode — great when you know the exact display size
// Next.js reserves space to prevent layout shift (CLS)
<Image
src="/avatar.jpg"
alt="User avatar"
width={48}
height={48}
className="rounded-full"
/>The width/height mode is underrated for one reason: it prevents Cumulative Layout Shift (CLS). Next.js uses those values to inject an inline `aspect-ratio` style, so the browser reserves space before the image loads. No janky reflow. For avatars, icons, fixed-size thumbnails — always use explicit dimensions.
Remote Images and the domains Config
If you're loading images from an external source — a CDN, Cloudinary, Supabase Storage, whatever — you need to tell Next.js about it. But there are two ways to do this, and one of them is way more flexible.
// next.config.ts
// Old way — domains array, matches hostname only
const config = {
images: {
domains: ['res.cloudinary.com', 'avatars.githubusercontent.com'],
},
};
// Better way — remotePatterns, matches protocol + hostname + pathname + port
const config = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/your-cloud-name/**',
},
{
protocol: 'https',
hostname: '**.supabase.co', // wildcard subdomain
pathname: '/storage/v1/object/public/**',
},
],
},
};
export default config;`remotePatterns` was added in Next.js 13 and the `domains` config is deprecated. Use `remotePatterns`. The wildcard subdomain matching (`**.supabase.co`) is especially useful when your Supabase project ID is part of the hostname and you don't want to hardcode it per environment.
Format and Quality: Let Next.js Do the Heavy Lifting
By default, Next.js serves WebP to browsers that support it, and falls back to the original format for those that don't. That's already a big win — WebP is typically 25-35% smaller than JPEG at equivalent quality. But there's more you can tune.
// next.config.ts — configure image optimization globally
const config = {
images: {
// Add AVIF support (even smaller than WebP, but slower to encode)
// Next.js will try AVIF first, then WebP, then original
formats: ['image/avif', 'image/webp'],
// Customize the breakpoints used to generate srcsets
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Cache optimized images longer (default is 60 seconds for TTL)
minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
},
};
export default config;A word on AVIF: it produces smaller files than WebP, but encoding is CPU-intensive. On Vercel, this is fine because images are cached after the first request and served from the edge on subsequent ones. On a self-hosted Node server with high traffic and cold caches, you might see a CPU spike. We enable AVIF on Vercel deployments without thinking twice. On self-hosted, we benchmark first.
The `quality` prop on individual images defaults to 75, which is a reasonable balance. For hero images where visual quality matters, go to 85-90. For thumbnails and avatars, you can drop to 60-65 without noticeable degradation. Test with your actual images — compression behavior varies a lot based on the source material.
// High quality for hero/showcase images
<Image
src="/hero-product.jpg"
alt="Product showcase"
width={1200}
height={630}
priority
quality={85}
/>
// Lower quality for grid thumbnails — nobody's going to notice
<Image
src={product.thumbnail}
alt={product.name}
fill
quality={65}
sizes="(max-width: 640px) 50vw, 25vw"
className="object-cover"
/>Placeholder Blur — Better UX, Minimal Setup
The `placeholder="blur"` prop shows a blurred low-resolution preview while the full image loads. It's a much better experience than a white box, and for static images (imported directly), it's completely automatic — Next.js generates the blur data URL at build time.
// Static import — blur placeholder is automatic
import heroImage from '@/public/hero.jpg';
<Image
src={heroImage}
alt="Hero"
placeholder="blur"
priority
/>
// Dynamic/remote image — you need to provide blurDataURL
// Generate this at build time or use a tiny base64 placeholder
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k="
/>For remote images with blur, you can generate the `blurDataURL` at build time using something like `plaiceholder` — it fetches the image and returns a tiny base64-encoded version. It's a build step worth adding for content-heavy sites like blogs or e-commerce. For user-uploaded images in a dynamic app, a static low-quality placeholder (like the hardcoded base64 above) is good enough.
A Quick Checklist Before You Ship
- Hero or above-the-fold images have priority={true}
- All fill-mode images have a sizes prop that matches the layout
- Remote image domains are configured via remotePatterns, not domains
- AVIF format is enabled in next.config.ts if you're on Vercel
- minimumCacheTTL is set — default 60s is too short for most static assets
- Static images use placeholder="blur" for better perceived performance
- CLS-sensitive images (avatars, cards) use explicit width and height
We run through this list on every template we ship at peal.dev. It takes about 20 minutes to audit an existing project, and it consistently moves Lighthouse performance scores by 10-20 points. That's not a number we made up — run PageSpeed Insights before and after, you'll see it.
The Next.js Image component is not a drop-in replacement for `<img>`. It's a full optimization pipeline. Use it like one.
One more thing: don't neglect your `alt` text for accessibility reasons, obviously, but also because descriptive alt text contributes to image SEO. Search engines use it. Screen readers use it. 'Image' and 'photo' are not alt texts. Write what's actually in the image.
Image performance is one of those things where the effort-to-impact ratio is absurdly good. You're not rewriting architecture or switching databases — you're adding a few props and updating a config file. Do it.
