I Tanked My Core Web Vitals Score With Next.js Images Here's How I Fixed It
technology

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.

2026-04-21
13 min read
I Tanked My Core Web Vitals Score With Next.js Images Here's How I Fixed It

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:

  1. Using <img> instead of Next.js <Image>
  2. No width or height specified
  3. 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)#

  1. Using regular <img> tags - Bypasses all Next.js optimizations. Always use <Image>.

  2. Forgetting width/height - The #1 cause of layout shift. Always provide dimensions or use fill.

  3. Not whitelisting external domains - Works in dev, breaks in production. Add to remotePatterns.

  4. Using priority on all images - Defeats the purpose. Only use for above-the-fold images.

  5. Serving massive images - 4000px images for 800px containers. Use appropriate sizes.

  6. Not testing on slow connections - Throttle your network in DevTools. See what users see.

  7. 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.

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

|

Have more questions? Contact us