Next.js

Fix next/image Layout Shift (CLS) in Next.js

next/image only prevents layout shift when it can reserve space. Eight fixes for CLS and LCP problems caused by images, each checked against the Next.js and web.dev documentation.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
11 min read

Adding images to a Next.js page is one of the most common ways to push Cumulative Layout Shift (CLS) out of the "good" range. web.dev defines a good CLS as 0.1 or less and a poor one as anything above 0.25, measured at the 75th percentile of page loads.

The next/image component does not prevent layout shift by magic: it prevents it when it knows how much space to reserve. The fixes below cover the cases where it does not, plus the closely related LCP and bandwidth problems that images cause.

This article is part of our comprehensive Next.js Performance Optimization guide.

The Problem: An Image With No Reserved Space#

The typical starting point looks like this:

typescript
// ❌ No dimensions, no reserved space
export function BlogPost({ post }) {
  return (
    <article>
      <img src={post.imageUrl} alt={post.title} />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

Three problems:

  1. A plain <img> instead of next/image, so no optimisation
  2. No width or height, so the browser cannot reserve space
  3. A remote image host that Next.js has not been told to allow

The browser lays out the heading and text first. When the image arrives, it pushes everything below it down. That movement is exactly what CLS measures.

Fix #1: Switch to the Next.js Image Component#

The next/image component extends the HTML <img> element with automatic optimisation, and it avoids layout shift once it has the dimensions it needs.

typescript
// ✅ 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>
  )
}

With a remote src, this alone usually produces a new error:

plaintext
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`

Fix #2: Allow External Image Hosts#

Next.js only optimises remote images from hosts you allow explicitly. The dedicated walkthrough for this error is Fix next/image Hostname Not Configured in Next.js.

The Deprecated Way#

javascript
// ❌ Old syntax (deprecated since Next.js 14)
// next.config.js
const nextConfig = {
  images: {
    domains: ['images.unsplash.com']
  }
}

The domains option still exists but has been deprecated since Next.js 14 in favour of remotePatterns, because it cannot restrict protocol, port or path.

The Correct Way#

javascript
// ✅ 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

Any protocol, hostname, port or path that does not match a pattern gets a 400 Bad Request from the optimiser. Restart the dev server after editing next.config.mjs.

Fix #3: Always Provide Width and Height#

This is the fix that addresses CLS directly, and it matches web.dev's CLS optimisation guidance: always include width and height (or reserve the space with aspect-ratio) so the browser can allocate space before the image loads.

The Problem#

typescript
// ❌ Fails for remote or dynamic src: no dimensions
<Image 
  src={post.imageUrl} 
  alt="Hero"
  // No width/height!
/>

For a remote or dynamic src, Next.js cannot read the file at build time, so it needs width and height (or fill). Only a static import (import hero from './hero.jpg') supplies them automatically.

The Solution#

typescript
// ✅ Provide the intrinsic dimensions
<Image 
  src="/hero.jpg" 
  alt="Hero"
  width={1200}
  height={600}
/>

width and height are the intrinsic size of the image in pixels. The Next.js docs are explicit that they are used to infer the aspect ratio so the browser can reserve space, and that they do not determine the rendered size. Here the browser reserves a 2:1 box at whatever width CSS gives the image, so the content below it no longer moves.

For Responsive Images#

typescript
// ✅ Use fill for responsive images
<div className="relative w-full h-64">
  <Image 
    src="/hero.jpg" 
    alt="Hero"
    fill
    sizes="100vw"
    className="object-cover"
  />
</div>

With fill, the parent sets the size and must be positioned (position: relative, fixed or absolute), because the image itself is absolutely positioned against it. Because the box is sized by the parent, there is no shift. If the image ignores the parent height instead, see How to set the next/image component to 100% height.

Fix #4: Use Aspect Ratio Containers#

Images with mixed aspect ratios (16:9, 4:3, square) make card grids uneven. Hard-coding a different width/height per image works for CLS but not for layout consistency.

The Inconsistent Version#

typescript
// ❌ 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

The Fix: Consistent Aspect Ratio#

typescript
// ✅ 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
        sizes="(max-width: 768px) 100vw, 800px"
        className="object-cover rounded-lg"
      />
    </div>
  )
}

The container reserves a 16:9 box before the image loads, and object-cover crops each image into it, whatever its original dimensions.

Tailwind Aspect Ratio Classes#

typescript
// aspect-square (1:1)
<div className="relative w-full aspect-square">
  <Image src={src} alt={alt} fill sizes="100vw" className="object-cover" />
</div>
 
// aspect-video (16:9)
<div className="relative w-full aspect-video">
  <Image src={src} alt={alt} fill sizes="100vw" className="object-cover" />
</div>
 
// aspect-[4/3] (custom ratio)
<div className="relative w-full aspect-[4/3]">
  <Image src={src} alt={alt} fill sizes="100vw" className="object-cover" />
</div>

Fix #5: Load Above-the-Fold Images Eagerly (The LCP Fix)#

This one is about Largest Contentful Paint rather than CLS. next/image defaults to loading="lazy", which defers an image until it nears the viewport. That is right for images below the fold and wrong for the hero image that is usually the LCP element.

The Problem#

typescript
// ❌ Hero image lazy-loads (bad for LCP)
<Image 
  src="/hero.jpg" 
  alt="Hero"
  width={1200}
  height={600}
/>

The Fix (Next.js 15 and Earlier)#

typescript
// ✅ Next.js ≤ 15: priority for the LCP image
<Image 
  src="/hero.jpg" 
  alt="Hero"
  width={1200}
  height={600}
  priority
/>

The Fix (Next.js 16)#

Starting with Next.js 16, priority is deprecated in favour of the preload prop. The docs add that in most cases you should use loading="eager" or fetchPriority="high" rather than preload:

typescript
// ✅ Next.js 16: eager loading with high fetch priority
<Image 
  src="/hero.jpg" 
  alt="Hero"
  width={1200}
  height={600}
  loading="eager"
  fetchPriority="high"
/>

Reserve preload for a single image you want requested from the <head> before it is discovered in the body. The docs say not to use it when several images could be the LCP element depending on the viewport, or together with loading or fetchPriority.

When to Load Eagerly#

Use it for:

  • The hero image
  • Any image that is the LCP element without scrolling

Don't use it for:

  • Images below the fold
  • Most thumbnails in a grid
  • Decorative images that are not the LCP element

Fix #6: Set the sizes Prop#

Without sizes, the browser assumes the image will be as wide as the viewport (100vw), which can make it download a far larger file than the layout needs.

typescript
// ✅ Tell the browser how wide the image is rendered
<Image 
  src="/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, 800px"
/>

sizes also changes the srcset Next.js generates. Without it, an image with a width gets a limited srcset (1x, 2x). A fill image gets a srcset built from deviceSizes, as if it spanned the full viewport. With sizes, Next.js generates a full width-based srcset (640w, 750w and so on) and the browser picks the best candidate. Separately, there is no reason to commit a 4000px original for an image that is never rendered wider than 800px.

Sizes Prop Examples#

typescript
// 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 (User Uploads)#

User-uploaded images have no dimensions known in advance. There are two ways to reserve space anyway.

Solution 1: Store Dimensions at Upload Time#

typescript
// When the user uploads an image
// (getImageDimensions is your own helper that reads the file header)
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 an Aspect Ratio Container#

typescript
// ✅ Force all user images to 16:9
<div className="relative w-full aspect-video">
  <Image 
    src={userImage.url}
    alt={userImage.alt}
    fill
    sizes="(max-width: 768px) 100vw, 800px"
    className="object-cover"
  />
</div>

Solution 2 needs no schema change and gives a uniform layout, at the cost of cropping. Solution 1 keeps every image's real proportions.

Fix #8: Blur Placeholder#

A placeholder does not fix CLS (the space is already reserved by the fixes above), but it gives the reserved box something to show while the image loads.

typescript
// ✅ Add blur placeholder
<Image 
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>

placeholder="blur" shows a blurred version of the image until it loads, and it must be used with blurDataURL. For a static import of a JPG, PNG, WebP or AVIF file, Next.js adds blurDataURL automatically. For a remote or dynamic image you must supply it yourself, and the docs warn that a large blurDataURL can hurt performance.

Generate Blur Placeholders#

The Next.js docs point to Plaiceholder for this. Its getPlaiceholder takes a Buffer, not a file path, and runs on the server:

typescript
// Server Component or build script
import fs from 'node:fs/promises'
import Image from 'next/image'
import { getPlaiceholder } from 'plaiceholder'
 
export default async function Hero() {
  const file = await fs.readFile('./public/hero.jpg')
  const { base64 } = await getPlaiceholder(file)
 
  return (
    <Image 
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={600}
      placeholder="blur"
      blurDataURL={base64}
    />
  )
}

Common Mistakes#

  1. Using regular <img> tags. This bypasses Next.js optimisation. Use <Image>.

  2. Forgetting width/height. This is the most direct cause of image layout shift. Always provide dimensions or use fill in a sized container.

  3. Not allowing external hosts. The hostname error appears in development as well, and unmatched URLs get a 400 from the optimiser. Add the host to remotePatterns.

  4. Loading every image eagerly. priority or preload on every image defeats the point. Keep it for the LCP image.

  5. Omitting sizes on fill images. The browser then assumes 100vw and may download a larger file than needed.

  6. Testing only on a fast connection. Throttle the network in DevTools to see the layout while images are still loading.

  7. Ignoring aspect ratios. Mixed ratios produce uneven grids. Use aspect-ratio containers.

A Reusable Image Component#

The fixes above combine into one component:

typescript
// 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} // Next.js 16: use loading="eager" / fetchPriority="high" instead
        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
/>

Testing Checklist#

plaintext
□ All images use <Image> component (no <img> tags)
□ All images have width/height or fill prop
□ fill images have a positioned parent and a sizes value
□ External hosts listed in images.remotePatterns
□ Only the LCP image loads eagerly (priority / preload / loading="eager")
□ Aspect ratios are consistent
□ Tested on a throttled connection
□ Lighthouse CLS ≤ 0.1
□ Field CLS (CrUX / Search Console) ≤ 0.1 at the 75th percentile
□ No console errors about images
□ Images load on production build

Lighthouse is a lab tool: web.dev notes that lab tools usually measure only the shifts that happen during page load, while field data covers the whole page lifetime. A green Lighthouse CLS can therefore still be a worse CLS for real users. Shifts within 500 ms of a click or key press are excluded from CLS.

Conclusion: Reserve the Space First#

Layout shift from images comes down to one question: does the browser know how much space the image will take before it arrives? next/image answers it when you give it width and height, or fill inside a sized, positioned container.

The rest follows from that: allow remote hosts in remotePatterns, set sizes on responsive images, load only the LCP image eagerly (with preload, loading="eager" or fetchPriority="high" on Next.js 16 rather than priority), and confirm the result in field data, not only in Lighthouse.

Further reading: Next.js 15 Partial Prerendering: Guide, Next.js + Supabase Performance: 10 Real Fixes.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.