← All posts

Next.js image optimization for LCP

LCP is where most Next.js sites lose Core Web Vitals. The Image component handles optimization — but only if you use priority, sizes, and fill correctly.

Next.js image optimization for LCP

LCP — Largest Contentful Paint — is the Core Web Vitals metric I spend the most time on for client sites. It measures how long until the biggest visible element on the page is rendered. For most marketing sites, that element is a hero image. For most Next.js sites I've audited, it's also where the biggest score gap lives.

Getting LCP under 2.5 seconds on mobile is achievable with Next.js's built-in image component, but only if you use it correctly. Most sites don't.

What Next.js <Image> actually does

next/image is a wrapper around a server-side image optimisation pipeline. When it serves an image, it:

None of this is magic — the browser still has to download the image. But you're downloading a WebP at the exact display size instead of a 4MB JPEG at 4000px wide. The difference on mobile is 5–10× smaller file sizes.

The priority prop is not optional for above-the-fold images

By default, next/image lazy-loads images — it doesn't start fetching until the image is near the viewport. For a hero image that's visible immediately on page load, this is backwards. Lazy loading the LCP element delays LCP.

Add priority to any image that's visible without scrolling:

<Image
  src="/hero.jpg"
  alt="..."
  fill
  priority // preloads the image; no lazy loading
  sizes="100vw"
/>

Without priority, Lighthouse will flag a "Largest Contentful Paint image was lazily loaded" warning and your LCP will be 0.5–1.5 seconds worse than it needs to be.

The sizes prop and why it matters

next/image generates a srcset with multiple resized versions of the image. The browser uses the sizes attribute to decide which version to download. If sizes is wrong, the browser downloads a larger image than it needs.

Most developers leave sizes at its default or set it to 100vw, which is correct for a full-width hero but wrong for everything else.

// Hero — full viewport width
<Image src="..." fill sizes="100vw" priority />

// Card in a 3-column grid
<Image src="..." fill sizes="(max-width: 768px) 100vw, 33vw" />

// Sidebar thumbnail
<Image src="..." width={120} height={90} sizes="120px" />

A wrong sizes value means the browser downloads a 1200px image for a 400px slot. That's wasted bandwidth and a slower LCP.

Remote images and the remotePatterns trap

If your images are hosted on an external service (Cloudinary, Uploadthing, an S3 bucket), you need to add the hostname to remotePatterns in next.config.ts. This is documented, but the error message when you forget it ("hostname is not configured") points at the image component, not the config, which makes it confusing to diagnose.

// next.config.ts
const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.cloudinary.com',
      },
    ],
  },
};

The double-star glob (**) matches any subdomain, which covers Cloudinary's versioned domains without requiring you to hardcode a specific subdomain.

Static imports for local images

For images that live in your repository (logos, icons, static hero images), use static imports instead of string paths:

import heroImage from '@/public/hero.jpg';

<Image src={heroImage} alt="..." priority />

Static imports let Next.js know the image dimensions at build time, which means it can generate correct width and height attributes without you specifying them. It also enables build-time blurHash generation for the placeholder="blur" prop, which fills the image space with a low-resolution preview while the full image loads — better UX and slightly better CLS.

The fill prop and its container requirement

fill positions the image to fill its nearest positioned ancestor. The most common mistake is forgetting to add position: relative (or absolute/fixed) to the container.

// Correct
<div className="relative h-[500px] w-full">
  <Image src="..." fill alt="..." sizes="100vw" />
</div>

// Broken — image has no positioned ancestor to fill
<div className="h-[500px] w-full">
  <Image src="..." fill alt="..." sizes="100vw" />
</div>

Without the positioned container, the image fills the nearest positioned ancestor further up the tree — often the viewport — and your layout breaks in ways that are hard to trace back to the image component.

Measuring the result

After implementing these changes on a client site, the standard before/after measurement is Lighthouse in Chrome DevTools with CPU 4× slowdown and mobile network throttling. Run it three times and average; single runs have too much variance.

The changes above — priority prop, correct sizes, static imports for above-fold images — typically move LCP from 4–7 seconds to 1.5–2.5 seconds on a simulated mobile connection. That's the difference between a Google score in the 40s and one in the 80s.