I Tanked My Core Web Vitals Score With Next.js Images Here's How I Fixed It
Added images to my Next.js app and watched my Core Web Vitals tank. After debugging for days, here are the 7 fixes that brought my CLS score back to green.
I Tanked My Core Web Vitals Score With Next.js Images: Here's How I Fixed It#
My Next.js site had a perfect Lighthouse score. 100 across the board. Then I added images.
CLS (Cumulative Layout Shift) went from 0.01 to 0.4. My "good" green score turned red. Google Search Console started showing warnings. My rankings dropped.
I thought Next.js Image component was supposed to handle this automatically. Turns out, I was doing everything wrong.
After two weeks of debugging, testing, and reading every GitHub issue about next/image, I finally got my CLS back to 0.02. Here's everything I learned.
This article is part of our comprehensive Next.js Performance Optimization guide.
What Went Wrong: My Lighthouse Score Disaster#
I launched my blog with placeholder images. Perfect scores:
- Performance: 100
- Accessibility: 100
- Best Practices: 100
- SEO: 100
- CLS: 0.01
Then I added real images from Unsplash. Next deploy:
- Performance: 78
- CLS: 0.4 (RED)
- LCP: 4.2s (was 1.1s)
My images were causing massive layout shifts. Content was jumping around as images loaded. Users were clicking the wrong links because buttons moved after images appeared.
The Problem: What I Was Doing Wrong#
Here's the code I had (don't do this):
// ❌ This destroyed my CLS score
export function BlogPost({ post }) {
return (
<article>
<img src={post.imageUrl} alt={post.title} />
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
Three critical mistakes:
- Using
<img>instead of Next.js<Image> - No width or height specified
- External images not whitelisted
The browser had no idea how much space to reserve for the image. It rendered the text, then the image loaded, pushing everything down. Massive layout shift.
Fix #1: Switch to Next.js Image Component (The Obvious One)#
This should have been obvious, but I was lazy.
// ✅ Step 1: Import Image from next/image
import Image from 'next/image'
export function BlogPost({ post }) {
return (
<article>
<Image
src={post.imageUrl}
alt={post.title}
width={800}
height={400}
/>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
But this alone didn't fix it. I got a new error:
Error: Invalid src prop (https://images.unsplash.com/photo-xxx) on `next/image`,
hostname "images.unsplash.com" is not configured under images in your `next.config.js`
On to fix #2.
Fix #2: Whitelist External Image Domains (The Security Thing)#
Next.js blocks external images by default for security. You need to whitelist domains.
What I Did Wrong First#
// ❌ Old syntax (deprecated in Next.js 14)
// next.config.js
const nextConfig = {
images: {
domains: ['images.unsplash.com']
}
}
This worked in Next.js 13 but is deprecated. Use remotePatterns instead.
The Correct Way#
// ✅ Correct: Use remotePatterns
// next.config.mjs
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'res.cloudinary.com',
pathname: '/**',
},
],
},
}
export default nextConfig
Now my external images loaded. But CLS was still bad.
Fix #3: Always Provide Width and Height (The Critical One)#
This is what actually fixed my CLS score.
The Problem#
// ❌ This still causes layout shift
<Image
src="/hero.jpg"
alt="Hero"
// No width/height!
/>
Without dimensions, the browser can't reserve space. The image loads, content shifts.
The Solution#
// ✅ Always provide dimensions
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // For above-the-fold images
/>
The browser reserves exactly 1200x600px before the image loads. No layout shift.
For Responsive Images#
// ✅ Use fill for responsive images
<div className="relative w-full h-64">
<Image
src="/hero.jpg"
alt="Hero"
fill
className="object-cover"
/>
</div>
The parent div sets the size, image fills it. Still no layout shift.
Fix #4: Use Aspect Ratio Containers (The Game Changer)#
This was my breakthrough moment. I had blog post images with different aspect ratios. Some were 16:9, some 4:3, some square.
What I Was Doing#
// ❌ Different sizes = inconsistent layout
<Image src={post.image} width={800} height={600} alt="" /> // 4:3
<Image src={post.image} width={800} height={450} alt="" /> // 16:9
<Image src={post.image} width={800} height={800} alt="" /> // 1:1
My blog looked messy. Images were different sizes.
The Fix: Consistent Aspect Ratio#
// ✅ Force all images to 16:9
export function BlogPostImage({ src, alt }) {
return (
<div className="relative w-full aspect-video">
<Image
src={src}
alt={alt}
fill
className="object-cover rounded-lg"
/>
</div>
)
}
Now all images are 16:9, regardless of original dimensions. Consistent, clean, no layout shift.
Tailwind Aspect Ratio Classes#
// aspect-square (1:1)
<div className="relative w-full aspect-square">
<Image src={src} alt={alt} fill className="object-cover" />
</div>
// aspect-video (16:9)
<div className="relative w-full aspect-video">
<Image src={src} alt={alt} fill className="object-cover" />
</div>
// aspect-[4/3] (custom ratio)
<div className="relative w-full aspect-[4/3]">
<Image src={src} alt={alt} fill className="object-cover" />
</div>
Fix #5: Priority for Above-the-Fold Images (The LCP Fix)#
My Largest Contentful Paint (LCP) was terrible. 4.2 seconds. The hero image was lazy-loading by default.
The Problem#
// ❌ Hero image lazy-loads (bad for LCP)
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
/>
Next.js lazy-loads all images by default. Great for images below the fold, terrible for hero images.
The Fix#
// ✅ Priority for above-the-fold images
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preloads this image
/>
LCP dropped from 4.2s to 1.3s. Huge improvement.
When to Use Priority#
Use priority for:
- Hero images
- Logo
- First image in the viewport
- Any image visible without scrolling
Don't use it for:
- Images below the fold
- Thumbnails in a grid (only the first few)
- Background images that aren't critical
Fix #6: Optimize Image Sizes (The Bandwidth Saver)#
I was serving 4000x3000px images for 800x600px containers. Wasting bandwidth and slowing down my site.
The Problem#
// ❌ Serving huge images
<Image
src="/hero-4000x3000.jpg" // 2.5MB file
alt="Hero"
width={800}
height={600}
/>
Next.js optimizes images, but it's still downloading a 2.5MB file to generate smaller versions.
The Fix: Use Appropriate Sizes#
// ✅ Serve appropriately sized images
<Image
src="/hero-1600x1200.jpg" // 200KB file
alt="Hero"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 800px"
/>
The sizes prop tells Next.js what size to generate for different viewports.
Sizes Prop Examples#
// Full width on mobile, 50% on desktop
sizes="(max-width: 768px) 100vw, 50vw"
// Fixed width
sizes="800px"
// Complex responsive
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
Fix #7: Handle Unknown Dimensions (The User Upload Problem)#
My blog has user-uploaded images. I don't know the dimensions ahead of time.
Solution 1: Store Dimensions in Database#
// When user uploads image
const dimensions = await getImageDimensions(file)
await db.images.create({
url: uploadedUrl,
width: dimensions.width,
height: dimensions.height,
})
// When rendering
<Image
src={image.url}
alt={image.alt}
width={image.width}
height={image.height}
/>
Solution 2: Use Aspect Ratio Container#
// ✅ Force all user images to 16:9
<div className="relative w-full aspect-video">
<Image
src={userImage.url}
alt={userImage.alt}
fill
className="object-cover"
/>
</div>
I went with solution 2. Simpler, consistent layout, no database changes needed.
Fix #8: Blur Placeholder (The Polish)#
This doesn't fix CLS, but it makes the loading experience way better.
// ✅ Add blur placeholder
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
The image fades in smoothly instead of popping in. Much nicer UX.
Generate Blur Placeholders#
// Use plaiceholder library
import { getPlaiceholder } from 'plaiceholder'
const { base64 } = await getPlaiceholder('/hero.jpg')
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
placeholder="blur"
blurDataURL={base64}
/>
Common Mistakes I Made (Learn From My Pain)#
-
Using regular
<img>tags - Bypasses all Next.js optimizations. Always use<Image>. -
Forgetting width/height - The #1 cause of layout shift. Always provide dimensions or use
fill. -
Not whitelisting external domains - Works in dev, breaks in production. Add to
remotePatterns. -
Using priority on all images - Defeats the purpose. Only use for above-the-fold images.
-
Serving massive images - 4000px images for 800px containers. Use appropriate sizes.
-
Not testing on slow connections - Throttle your network in DevTools. See what users see.
-
Ignoring aspect ratios - Inconsistent image sizes look unprofessional. Use aspect ratio containers.
My Final Image Component#
After all this, here's the reusable component I built:
// components/OptimizedImage.tsx
import Image from 'next/image'
import { cn } from '@/lib/utils'
interface OptimizedImageProps {
src: string
alt: string
aspectRatio?: 'square' | 'video' | '4/3'
priority?: boolean
className?: string
}
export function OptimizedImage({
src,
alt,
aspectRatio = 'video',
priority = false,
className,
}: OptimizedImageProps) {
const aspectClass = {
square: 'aspect-square',
video: 'aspect-video',
'4/3': 'aspect-[4/3]',
}[aspectRatio]
return (
<div className={cn('relative w-full', aspectClass)}>
<Image
src={src}
alt={alt}
fill
priority={priority}
className={cn('object-cover', className)}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px"
/>
</div>
)
}
// Usage
<OptimizedImage
src="/hero.jpg"
alt="Hero"
aspectRatio="video"
priority
/>
My Testing Checklist#
Before deploying, I run through this:
□ All images use <Image> component (no <img> tags)
□ All images have width/height or fill prop
□ External domains whitelisted in next.config.js
□ Hero image has priority prop
□ No other images have priority
□ Aspect ratios are consistent
□ Tested on slow 3G connection
□ Lighthouse CLS score < 0.1
□ No console errors about images
□ Images load on production build
The Results#
After implementing all these fixes:
Before:
- CLS: 0.4 (Poor)
- LCP: 4.2s (Poor)
- Performance: 78
After:
- CLS: 0.02 (Good)
- LCP: 1.3s (Good)
- Performance: 98
My Google Search Console warnings disappeared. Rankings recovered. Users stopped complaining about jumpy layouts.
FAQ#
Why is my CLS score bad even with Next.js Image component?#
The Image component needs explicit width and height props to prevent layout shift. Without dimensions, the browser cannot reserve space before the image loads, causing content to jump when the image appears. Always provide width/height or use the fill prop with a sized container.
Do I need to whitelist every external image domain?#
Yes. Next.js requires you to whitelist external domains in next.config.js using remotePatterns for security. Images from non-whitelisted domains will fail to load in production. This is a security feature to prevent malicious image sources.
Should I use fill or width/height for responsive images?#
Use fill when you want the image to fill its container (like hero images or backgrounds). Use explicit width/height when you know the exact dimensions. Fill requires a positioned parent container (relative, absolute, or fixed).
Why are my images not optimizing in production?#
Common causes: using regular img tags instead of Image component, external domains not whitelisted in next.config.js, or missing loader configuration for custom image CDNs. Check your config and ensure you're using the Image component everywhere.
How do I fix layout shift for images with unknown dimensions?#
Use aspect ratio containers with the fill prop, or fetch image dimensions at build time and pass them as props. For user-uploaded images, store dimensions in your database when they upload, or force all images into a consistent aspect ratio container.
Related Articles#
- Next.js Performance Optimization: 10 Essential Techniques
- I Fixed Next.js Hydration Errors After 3 Days of Debugging
- Next.js Turbopack Stuck on Compiling How to Fix
- Building SaaS with Next.js and Supabase
Conclusion: Images Are Worth Getting Right#
Layout shift is one of the most annoying UX issues. Content jumping around as images load makes your site feel broken and unprofessional.
The Next.js Image component solves this, but only if you use it correctly. Always provide dimensions, whitelist external domains, use priority for above-the-fold images, and test on slow connections.
My CLS went from 0.4 (terrible) to 0.02 (excellent) by following these fixes. Your users will thank you, and Google will reward you with better rankings.
Now go fix those layout shifts. Your Core Web Vitals score will thank you.
Frequently Asked Questions
Continue Reading
Next.js + Supabase Performance Optimization: From Slow to Lightning Fast
Transform your slow Next.js and Supabase application into a speed demon. Real-world optimization techniques that reduced load times by 70% and improved Core Web Vitals scores.
I Cut My Next.js + Supabase App Load Time by 73% - Here Are the 5 Techniques That Actually Worked
Real performance optimization results from a production SaaS app. These battle-tested techniques reduced load times from 4.2s to 1.1s and improved Core Web Vitals scores across the board.
Why Your Supabase Queries Are Slow (And Exactly How to Fix Them)
Slow Supabase queries kill your app feel and inflate your bill. Here are the six causes I keep seeing in production apps, and the exact SQL and code fixes for each one.